@bongos/core 1.19.1010 → 1.19.1012
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 +52 -22
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +6 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +67 -1
- package/docs/api-reference.md +8 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/ideas/module.json +1 -0
- package/modules/ideas/routes/sky-mine.js +34 -0
- package/modules/ideas/sky-mine.js +75 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/sky.js +37 -0
- package/modules/lifecycle/sky.js +261 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/gen-file-map.js +29 -12
- package/src/module-api.js +1 -1
- package/tests/file_map.mjs +35 -3
- package/tests/sky_mine.mjs +138 -0
- package/tests/sky_read.mjs +265 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/sky.js — ONE READ FOR THE WHOLE PROJECT SKY (task 1004230,
|
|
4
|
+
// sky part 2 of 5; goal 1000095; ADR 0331, ADR 0227 §11).
|
|
5
|
+
//
|
|
6
|
+
// WHY ONE READ. The prototype's snapshot (docs/design/mocks/sky/snapshot.js)
|
|
7
|
+
// needed about 120 requests to assemble what one page draws: the goal graph, a
|
|
8
|
+
// detail read per goal for its criteria, a progress rollup per version for the
|
|
9
|
+
// criterion→task join, and a task list per goal. The two sky pages (parts 3 and
|
|
10
|
+
// 4) draw all of it at once, so they get it at once — five queries, one JSON.
|
|
11
|
+
//
|
|
12
|
+
// WHAT IT IS NOT. Not a second goal graph: `goals` and `edges` come from
|
|
13
|
+
// goal-graph.js's own read, so the sky's wells are the map's nodes and the sky's
|
|
14
|
+
// routes are the map's edges by construction, never a copy that can drift. Not
|
|
15
|
+
// the refused whole-corpus task map either (ADR 0227 §1): tasks come back
|
|
16
|
+
// compact and grouped under their goal, and the page draws them only inside one
|
|
17
|
+
// goal past a zoom threshold — dust below it (§11).
|
|
18
|
+
//
|
|
19
|
+
// THE JOIN THAT BITES. A criterion's tasks live in `task_criteria`; goal detail
|
|
20
|
+
// returns only COUNTS of them, and the version progress rollup is the one place
|
|
21
|
+
// the ids are joined. Rather than call that rollup once per version, the join is
|
|
22
|
+
// read ONCE for every goal-bearing criterion, as the same flat (criterion, task)
|
|
23
|
+
// rows `criterionProgress` folds — so `tests/sky_read.mjs` can hand the same rows
|
|
24
|
+
// to `buildCriterionProgress` and to this shaper and pin that a task's criterion
|
|
25
|
+
// link is identical on both.
|
|
26
|
+
//
|
|
27
|
+
// VISIBILITY (ADR 0112's leak rule, as goal-graph.js applies it). A private goal
|
|
28
|
+
// the viewer may not read stays a well — its counts, its edges, its ring count
|
|
29
|
+
// and its task count are structure, not secrets — but every piece of PROSE on it
|
|
30
|
+
// comes back null: title, subtitle, category, each criterion's text, each task's
|
|
31
|
+
// title. The graph only had a title and a category to hide; the sky carries more
|
|
32
|
+
// prose per goal, and hiding the title while shipping the criteria under it would
|
|
33
|
+
// be the leak wearing a different field name.
|
|
34
|
+
//
|
|
35
|
+
// THE CACHE. Sixty seconds, in-process, keyed by what decides visibility (the
|
|
36
|
+
// Archon flag, else the viewer id) — a cached payload shaped for one viewer is
|
|
37
|
+
// never handed to another. The response says whether it was served from cache
|
|
38
|
+
// and when it was taken, so a page that just filed a thought can tell why the
|
|
39
|
+
// sky has not moved yet.
|
|
40
|
+
|
|
41
|
+
const api = require('../../src/module-api');
|
|
42
|
+
const { pool } = api;
|
|
43
|
+
const goalGraph = require('./goal-graph');
|
|
44
|
+
const goalCategory = require('./goal-category');
|
|
45
|
+
const { isPendingReview } = require('./done-when');
|
|
46
|
+
const { TERMINAL_STATUSES } = require('./task-status');
|
|
47
|
+
|
|
48
|
+
const SKY_CACHE_TTL_MS = 60 * 1000;
|
|
49
|
+
|
|
50
|
+
// skyRows — the raw reads. Returns { goals, edges, extras, criteria, tasks }:
|
|
51
|
+
// `goals` + `edges` exactly as goal-graph.js reads them; `extras` one row per
|
|
52
|
+
// goal with the two columns the graph does not carry (subtitle, created_at);
|
|
53
|
+
// `criteria` one FLAT row per (criterion, task) pair — a criterion with no task
|
|
54
|
+
// arrives once with null task_* (the LEFT JOIN, the criterionProgress shape);
|
|
55
|
+
// `tasks` one compact row per task under a goal. deps.pool honours the test seam.
|
|
56
|
+
async function skyRows({ versionId = null, viewerId = null, viewerIsArchon = false } = {}, deps = {}) {
|
|
57
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
58
|
+
const version = Array.isArray(versionId) ? (versionId[0] ?? null) : versionId;
|
|
59
|
+
const [graph, extras, criteria, tasks] = await Promise.all([
|
|
60
|
+
(deps.goalGraphRows || goalGraph.goalGraphRows)({ versionId: version, viewerId, viewerIsArchon }, deps),
|
|
61
|
+
activePool.query(
|
|
62
|
+
`SELECT g.id::text AS goal_id, g.subtitle, g.created_at
|
|
63
|
+
FROM goals g
|
|
64
|
+
WHERE ($1::text IS NULL OR g.version_id = $1)`,
|
|
65
|
+
[version]
|
|
66
|
+
),
|
|
67
|
+
activePool.query(
|
|
68
|
+
// The criterion→task join, read once. Flat rows on purpose (see the header):
|
|
69
|
+
// the fold happens in shapeSky so a test can compare it with the rollup's.
|
|
70
|
+
`SELECT c.id::text AS id, c.goal_id::text AS goal_id, c.criterion_id, c.criterion_md,
|
|
71
|
+
c.satisfied, c.sort_order,
|
|
72
|
+
t.id::text AS task_id, t.status AS task_status
|
|
73
|
+
FROM done_when_criteria c
|
|
74
|
+
JOIN goals g ON g.id = c.goal_id
|
|
75
|
+
LEFT JOIN task_criteria tc ON tc.criterion_id = c.id
|
|
76
|
+
LEFT JOIN tasks t ON t.id = tc.task_id
|
|
77
|
+
WHERE c.goal_id IS NOT NULL
|
|
78
|
+
AND ($1::text IS NULL OR g.version_id = $1)
|
|
79
|
+
ORDER BY c.goal_id, c.sort_order, c.criterion_id, t.id`,
|
|
80
|
+
[version]
|
|
81
|
+
),
|
|
82
|
+
activePool.query(
|
|
83
|
+
// Compact by construction: no description, no value summary, no notes.
|
|
84
|
+
// created_at is truncated to the DAY in SQL so the payload carries ten
|
|
85
|
+
// characters per task, not twenty-seven, and the clock draws by day anyway.
|
|
86
|
+
`SELECT t.id::text AS id, t.goal_id::text AS goal_id, t.title, t.status,
|
|
87
|
+
t.credits_reward, t.created_at::date::text AS created_on,
|
|
88
|
+
CASE WHEN t.source_ref ~ '^idea:[0-9]+$' THEN substring(t.source_ref from 6) END AS idea_id
|
|
89
|
+
FROM tasks t
|
|
90
|
+
JOIN goals g ON g.id = t.goal_id
|
|
91
|
+
WHERE ($1::text IS NULL OR g.version_id = $1)
|
|
92
|
+
ORDER BY t.goal_id, t.id`,
|
|
93
|
+
[version]
|
|
94
|
+
),
|
|
95
|
+
]);
|
|
96
|
+
return { goals: graph.goals, edges: graph.edges, extras: extras.rows, criteria: criteria.rows, tasks: tasks.rows };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// foldCriteria — the flat (criterion, task) rows into one entry per criterion
|
|
100
|
+
// with its task ids and the derived pending_review flag. The same fold
|
|
101
|
+
// buildCriterionProgress does, minus the cnum and the per-status counts the sky
|
|
102
|
+
// does not draw. Exported so the equivalence test can drive it on its own.
|
|
103
|
+
function foldCriteria(rows = []) {
|
|
104
|
+
const byId = new Map();
|
|
105
|
+
for (const row of rows) {
|
|
106
|
+
let c = byId.get(String(row.id));
|
|
107
|
+
if (!c) {
|
|
108
|
+
c = {
|
|
109
|
+
id: String(row.id),
|
|
110
|
+
goal_id: row.goal_id != null ? String(row.goal_id) : null,
|
|
111
|
+
criterion_id: row.criterion_id,
|
|
112
|
+
criterion_md: row.criterion_md,
|
|
113
|
+
satisfied: row.satisfied === true,
|
|
114
|
+
task_ids: [],
|
|
115
|
+
_unshipped: 0,
|
|
116
|
+
};
|
|
117
|
+
byId.set(c.id, c);
|
|
118
|
+
}
|
|
119
|
+
if (row.task_id != null) {
|
|
120
|
+
c.task_ids.push(String(row.task_id));
|
|
121
|
+
if (!TERMINAL_STATUSES.includes(row.task_status)) c._unshipped += 1;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return [...byId.values()].map((c) => {
|
|
125
|
+
const { _unshipped, ...rest } = c;
|
|
126
|
+
return {
|
|
127
|
+
...rest,
|
|
128
|
+
pending_review: isPendingReview({ satisfied: c.satisfied, linked: c.task_ids.length, unshipped: _unshipped }),
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// shapeSky — the wire shape. Pure, so a test pins it with no database.
|
|
134
|
+
//
|
|
135
|
+
// goals: [{ goal_id, title, subtitle, status, version_id, category, task_total,
|
|
136
|
+
// task_done, created_at, criteria: [{ id, criterion_id, criterion_md,
|
|
137
|
+
// satisfied, pending_review, task_ids }], tasks: [{ id, title, status,
|
|
138
|
+
// credits_reward, created_on, idea_id }] }]
|
|
139
|
+
// edges: [{ from, to, count }] — `from` waits on `to`, the graph's own
|
|
140
|
+
// counts: { goals, edges, criteria, tasks }
|
|
141
|
+
//
|
|
142
|
+
// A criterion or a task whose goal is not among the wells is DROPPED — the wells
|
|
143
|
+
// are the graph's nodes (goals carrying at least one task, in the version asked
|
|
144
|
+
// for), and a ring drawn around no well is a line pointing at nothing.
|
|
145
|
+
function shapeSky({ goals = [], edges = [], extras = [], criteria = [], tasks = [] } = {}, categoryById = new Map()) {
|
|
146
|
+
const graph = goalGraph.shapeGraph({ goals, edges }, categoryById);
|
|
147
|
+
const visibleById = new Map(goals.map((g) => [String(g.goal_id), g.visible !== false]));
|
|
148
|
+
const extraById = new Map(extras.map((x) => [String(x.goal_id), x]));
|
|
149
|
+
const wells = new Map(graph.nodes.map((n) => [n.goal_id, { ...n, criteria: [], tasks: [] }]));
|
|
150
|
+
|
|
151
|
+
let criteriaCount = 0;
|
|
152
|
+
for (const c of foldCriteria(criteria)) {
|
|
153
|
+
const well = c.goal_id != null ? wells.get(c.goal_id) : null;
|
|
154
|
+
if (!well) continue;
|
|
155
|
+
const visible = visibleById.get(c.goal_id) !== false;
|
|
156
|
+
const { goal_id, ...entry } = c;
|
|
157
|
+
well.criteria.push({ ...entry, criterion_md: visible ? entry.criterion_md : null });
|
|
158
|
+
criteriaCount += 1;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let taskCount = 0;
|
|
162
|
+
for (const t of tasks) {
|
|
163
|
+
const gid = t.goal_id != null ? String(t.goal_id) : null;
|
|
164
|
+
const well = gid != null ? wells.get(gid) : null;
|
|
165
|
+
if (!well) continue;
|
|
166
|
+
const visible = visibleById.get(gid) !== false;
|
|
167
|
+
well.tasks.push({
|
|
168
|
+
id: String(t.id),
|
|
169
|
+
title: visible ? t.title : null,
|
|
170
|
+
status: t.status,
|
|
171
|
+
credits_reward: t.credits_reward == null ? null : Number(t.credits_reward),
|
|
172
|
+
created_on: t.created_on || null,
|
|
173
|
+
idea_id: t.idea_id != null ? String(t.idea_id) : null,
|
|
174
|
+
});
|
|
175
|
+
taskCount += 1;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const out = [...wells.values()].map((w) => {
|
|
179
|
+
const visible = visibleById.get(w.goal_id) !== false;
|
|
180
|
+
const x = extraById.get(w.goal_id) || {};
|
|
181
|
+
const { depends_on, depended_on_by, ...node } = w;
|
|
182
|
+
return {
|
|
183
|
+
...node,
|
|
184
|
+
subtitle: visible ? (x.subtitle ?? null) : null,
|
|
185
|
+
created_at: x.created_at instanceof Date ? x.created_at.toISOString() : (x.created_at ?? null),
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
goals: out,
|
|
191
|
+
edges: graph.edges,
|
|
192
|
+
counts: { goals: out.length, edges: graph.edges.length, criteria: criteriaCount, tasks: taskCount },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// sky — read + shape. `categoryIndex` is injectable so the whole path runs in a
|
|
197
|
+
// test with a fake pool and no category table (the goal-graph.js precedent).
|
|
198
|
+
async function sky(opts = {}, deps = {}) {
|
|
199
|
+
const [rows, byId] = await Promise.all([
|
|
200
|
+
skyRows(opts, deps),
|
|
201
|
+
(deps.categoryIndex || goalCategory.categoryIndex)(deps),
|
|
202
|
+
]);
|
|
203
|
+
return shapeSky(rows, byId);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// skyCache — the 60-second memo, keyed by what decides what the viewer may see.
|
|
207
|
+
// `now` is injectable so a test can move the clock instead of waiting a minute.
|
|
208
|
+
function skyCache({ ttlMs = SKY_CACHE_TTL_MS, now = Date.now } = {}) {
|
|
209
|
+
const entries = new Map();
|
|
210
|
+
return {
|
|
211
|
+
key({ viewerId, viewerIsArchon, versionId }) {
|
|
212
|
+
return `${viewerIsArchon === true ? 'archon' : `b${viewerId}`}|${versionId == null ? '' : String(versionId)}`;
|
|
213
|
+
},
|
|
214
|
+
get(key) {
|
|
215
|
+
const e = entries.get(key);
|
|
216
|
+
if (!e) return null;
|
|
217
|
+
if (now() - e.at > ttlMs) { entries.delete(key); return null; }
|
|
218
|
+
return e;
|
|
219
|
+
},
|
|
220
|
+
set(key, payload) {
|
|
221
|
+
const e = { at: now(), payload };
|
|
222
|
+
entries.set(key, e);
|
|
223
|
+
return e;
|
|
224
|
+
},
|
|
225
|
+
clear() { entries.clear(); },
|
|
226
|
+
ttlSeconds: Math.round(ttlMs / 1000),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// skyHandler — GET /sky. A factory, so a test drives it with a stub read, a
|
|
231
|
+
// stub cache clock and no database. Identity comes from the authenticated
|
|
232
|
+
// request, never the query (goal-graph.js's rule, for the same reason: a
|
|
233
|
+
// caller-supplied id would let anyone ask as somebody else).
|
|
234
|
+
function skyHandler({ read = sky, log = console, cache = skyCache() } = {}) {
|
|
235
|
+
return async function getSky(req, res) {
|
|
236
|
+
try {
|
|
237
|
+
const opts = {
|
|
238
|
+
versionId: (req.query && req.query.version) || null,
|
|
239
|
+
viewerId: req.builder.id,
|
|
240
|
+
viewerIsArchon: req.builder.rank === 'archon',
|
|
241
|
+
};
|
|
242
|
+
const key = cache.key(opts);
|
|
243
|
+
let entry = cache.get(key);
|
|
244
|
+
let hit = true;
|
|
245
|
+
if (!entry) {
|
|
246
|
+
hit = false;
|
|
247
|
+
entry = cache.set(key, await read(opts));
|
|
248
|
+
}
|
|
249
|
+
res.set('Cache-Control', 'private, max-age=60');
|
|
250
|
+
return res.json({
|
|
251
|
+
...entry.payload,
|
|
252
|
+
cache: { hit, ttl_seconds: cache.ttlSeconds, taken_at: new Date(entry.at).toISOString() },
|
|
253
|
+
});
|
|
254
|
+
} catch (err) {
|
|
255
|
+
log.error('[gds] GET /sky', err);
|
|
256
|
+
return res.fail('sky_failed', { status: 500, message: 'internal error' });
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
module.exports = { sky, skyRows, shapeSky, foldCriteria, skyCache, skyHandler, SKY_CACHE_TTL_MS };
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1012",
|
|
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.1012",
|
|
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.1012",
|
|
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",
|
|
@@ -91,16 +91,31 @@ function materializedNames(absDir) { return new Set(Object.keys(readModuleSkills
|
|
|
91
91
|
// The subdirs of `relDir` holding at least one TRACKED file (task 1004213). An untracked one is the
|
|
92
92
|
// builder's own — a locally-installed plugin skill, or the empty husk a plugin extract leaves — and
|
|
93
93
|
// listing it would publish one machine's tooling into a shared doc on every post-merge regen.
|
|
94
|
-
// null when `root` is not the top of a git work tree (a packaged install, ADR 0108 §1)
|
|
94
|
+
// tracked: null when `root` is not the top of a git work tree (a packaged install, ADR 0108 §1).
|
|
95
|
+
// gitError: git COULD not answer (task 1004222) — a safe.directory refusal, git off PATH, a broken
|
|
96
|
+
// GIT_DIR, ENOBUFS. That also falls back to the disk listing, but loudly: main() warns and fails --check.
|
|
97
|
+
const GIT_MAX_BUFFER = 64 * 1024 * 1024;
|
|
98
|
+
// Discovery's own refusal. A broken GIT_DIR also exits 128 "not a git repository: '<dir>'", without this tail.
|
|
99
|
+
const NOT_A_REPO = /not a git repository \(or any (of the )?parent/;
|
|
95
100
|
function trackedSubdirs(root, relDir) {
|
|
96
|
-
|
|
101
|
+
// LC_ALL=C: the refusal is matched by its text, which a translated git would not print.
|
|
102
|
+
const git = (args) => execFileSync('git', args, {
|
|
103
|
+
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: GIT_MAX_BUFFER, env: { ...process.env, LC_ALL: 'C' },
|
|
104
|
+
});
|
|
97
105
|
try {
|
|
98
|
-
if (git(['rev-parse', '--show-prefix']).trim() !== '') return null;
|
|
99
|
-
return
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
106
|
+
if (git(['rev-parse', '--show-prefix']).trim() !== '') return { tracked: null, gitError: null };
|
|
107
|
+
return {
|
|
108
|
+
tracked: new Set(git(['ls-files', '-z', '--', relDir]).split('\0')
|
|
109
|
+
.map((p) => p.slice(relDir.length + 1).split('/'))
|
|
110
|
+
.filter((segs) => segs.length > 1)
|
|
111
|
+
.map((segs) => segs[0])),
|
|
112
|
+
gitError: null,
|
|
113
|
+
};
|
|
114
|
+
} catch (err) {
|
|
115
|
+
const stderr = String(err.stderr || '').trim();
|
|
116
|
+
if (err.status === 128 && NOT_A_REPO.test(stderr)) return { tracked: null, gitError: null };
|
|
117
|
+
return { tracked: null, gitError: stderr.split('\n')[0] || err.code || err.message };
|
|
118
|
+
}
|
|
104
119
|
}
|
|
105
120
|
|
|
106
121
|
// Immediate subdirectories of an absolute dir, sorted by name (deterministic); materialised
|
|
@@ -120,7 +135,7 @@ function listSubdirs(absDir) {
|
|
|
120
135
|
function buildSection(section, notes) {
|
|
121
136
|
const absDir = path.join(REPO_ROOT, section.dir);
|
|
122
137
|
const present = listSubdirs(absDir);
|
|
123
|
-
const tracked = trackedSubdirs(REPO_ROOT, section.dir);
|
|
138
|
+
const { tracked, gitError } = trackedSubdirs(REPO_ROOT, section.dir);
|
|
124
139
|
const names = tracked ? present.filter((n) => tracked.has(n)) : present;
|
|
125
140
|
const sectionNotes = (notes && notes[section.notesKey]) || {};
|
|
126
141
|
const entries = names.map((name) => {
|
|
@@ -135,7 +150,7 @@ function buildSection(section, notes) {
|
|
|
135
150
|
// A note is a ghost only when its dir is gone from disk — one not yet `git add`ed is not.
|
|
136
151
|
const onDisk = new Set(present);
|
|
137
152
|
const stale = Object.keys(sectionNotes).filter((k) => !onDisk.has(k)).map((k) => `${section.notesKey}.${k}`);
|
|
138
|
-
return { ...section, absDir, entries, missing, stale };
|
|
153
|
+
return { ...section, absDir, entries, missing, stale, gitError };
|
|
139
154
|
}
|
|
140
155
|
|
|
141
156
|
function buildModel() {
|
|
@@ -244,6 +259,7 @@ function computeWrites() {
|
|
|
244
259
|
const model = buildModel();
|
|
245
260
|
const missing = model.flatMap((m) => m.missing);
|
|
246
261
|
const stale = model.flatMap((m) => m.stale);
|
|
262
|
+
const gitErrors = [...new Set(model.map((m) => m.gitError).filter(Boolean))];
|
|
247
263
|
const writes = [];
|
|
248
264
|
let markersMissing = [];
|
|
249
265
|
let mapText = '';
|
|
@@ -258,7 +274,7 @@ function computeWrites() {
|
|
|
258
274
|
mapText = next; // coverage reads the POST-generation text, so a regen never changes the verdict
|
|
259
275
|
}
|
|
260
276
|
const modules = moduleCoverage(mapText);
|
|
261
|
-
return { model, writes, missing, stale, markersMissing, modules };
|
|
277
|
+
return { model, writes, missing, stale, gitErrors, markersMissing, modules };
|
|
262
278
|
}
|
|
263
279
|
|
|
264
280
|
function main() {
|
|
@@ -267,7 +283,7 @@ function main() {
|
|
|
267
283
|
process.stdout.write(JSON.stringify(buildModel(), null, 2) + '\n');
|
|
268
284
|
return;
|
|
269
285
|
}
|
|
270
|
-
const { writes, missing, stale, markersMissing, modules } = computeWrites();
|
|
286
|
+
const { writes, missing, stale, gitErrors, markersMissing, modules } = computeWrites();
|
|
271
287
|
|
|
272
288
|
// A missing note (dir without a note) or a stale note (note without a dir) is a hard failure in
|
|
273
289
|
// BOTH modes: in --check it fails CI; in write mode we still report it (and render a visible
|
|
@@ -275,6 +291,7 @@ function main() {
|
|
|
275
291
|
const problems = [];
|
|
276
292
|
if (missing.length) problems.push(`notes MISSING for: ${missing.join(', ')} — add a one-line note to docs/file-map.notes.json`);
|
|
277
293
|
if (stale.length) problems.push(`notes are STALE (no such dir) — remove from docs/file-map.notes.json: ${stale.join(', ')}`);
|
|
294
|
+
for (const e of gitErrors) problems.push(`git could not list tracked files (${e}) — every dir on disk was listed, untracked plugin installs included; fix git (PATH, safe.directory) and re-run (task 1004222)`);
|
|
278
295
|
if (markersMissing.length) problems.push(`docs/file-map.md is missing generated markers for: ${markersMissing.join(', ')}`);
|
|
279
296
|
if (modules.undocumented.length) problems.push(`modules with NO docs/file-map.md entry: ${modules.undocumented.map((k) => `${MODULES_DIR}/${k}`).join(', ')} — add an entry to the modules/ block of docs/file-map.md (task 1004066)`);
|
|
280
297
|
for (const d of modules.defects) problems.push(`module-coverage SCAN DEFECT — ${d}`);
|
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.1012'; // 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');
|
package/tests/file_map.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// (task 1276 / ADR 0066). Mirrors tests/session_index.mjs + tests/repo_map.mjs.
|
|
3
3
|
import assert from 'node:assert/strict';
|
|
4
4
|
import { test } from 'node:test';
|
|
5
|
-
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
6
6
|
import { readFileSync, existsSync, readdirSync, mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { createRequire } from 'node:module';
|
|
@@ -90,10 +90,19 @@ function fixtureRoot({ git }) {
|
|
|
90
90
|
}
|
|
91
91
|
return root;
|
|
92
92
|
}
|
|
93
|
-
const skillsSectionAt = (cwd) => JSON.parse(execFileSync(process.execPath,
|
|
93
|
+
const skillsSectionAt = (cwd, env) => JSON.parse(execFileSync(process.execPath,
|
|
94
94
|
['-e', `process.stdout.write(JSON.stringify(require(${JSON.stringify(GEN)}).buildModel()))`],
|
|
95
|
-
{ cwd, encoding: 'utf8' })).find((s) => s.id === 'skills');
|
|
95
|
+
{ cwd, encoding: 'utf8', env })).find((s) => s.id === 'skills');
|
|
96
96
|
const skillNamesAt = (cwd) => skillsSectionAt(cwd).entries.map((e) => e.name);
|
|
97
|
+
function genAt(cwd, args, env) {
|
|
98
|
+
const r = spawnSync(process.execPath, [GEN, ...args], { cwd, encoding: 'utf8', env });
|
|
99
|
+
return { code: r.status, stderr: r.stderr };
|
|
100
|
+
}
|
|
101
|
+
// Two ways git fails to answer inside a real work tree (task 1004222): no git on PATH (ENOENT), and a
|
|
102
|
+
// GIT_DIR naming nothing — exit 128 like "not a repository", but not discovery's own refusal.
|
|
103
|
+
// PATH is dropped under every casing: Windows spells it `Path`, and a copy of both keys is ambiguous.
|
|
104
|
+
const withoutGit = (root) => ({ ...Object.fromEntries(Object.entries(process.env).filter(([k]) => k.toUpperCase() !== 'PATH')), PATH: path.join(root, 'no-bin') });
|
|
105
|
+
const brokenGitDir = (root) => ({ ...process.env, GIT_DIR: path.join(root, 'no-such-git-dir') });
|
|
97
106
|
|
|
98
107
|
test('--check FAILS (exit 2) when a new tracked skill has no note — a skill can no longer land undescribed', () => {
|
|
99
108
|
const root = fixtureRoot({ git: true });
|
|
@@ -128,15 +137,38 @@ test('outside a git work tree (a packaged install) every dir on disk is still li
|
|
|
128
137
|
const nested = path.join(parent, 'instance');
|
|
129
138
|
try {
|
|
130
139
|
assert.deepEqual(skillNamesAt(root), ['empty-husk', 'local-plugin', 'new-skill']);
|
|
140
|
+
// Silently: not being in a repository is the packaged install's normal state, not a git failure.
|
|
141
|
+
assert.equal(skillsSectionAt(root).gitError, null);
|
|
142
|
+
assert.doesNotMatch(genAt(root, ['--check']).stderr, /git could not/);
|
|
131
143
|
// A root that is a SUBDIR of some repo is not that repo's top: its files are not the repo's to judge.
|
|
132
144
|
mkdirSync(path.join(nested, '.claude', 'skills', 'inner'), { recursive: true });
|
|
133
145
|
assert.deepEqual(skillNamesAt(nested), ['inner']);
|
|
146
|
+
assert.equal(skillsSectionAt(nested).gitError, null);
|
|
134
147
|
} finally {
|
|
135
148
|
rmSync(root, { recursive: true, force: true });
|
|
136
149
|
rmSync(parent, { recursive: true, force: true });
|
|
137
150
|
}
|
|
138
151
|
});
|
|
139
152
|
|
|
153
|
+
test('a git failure inside a work tree is NOT "not a repository" — it warns in write mode and fails --check', () => {
|
|
154
|
+
const root = fixtureRoot({ git: true });
|
|
155
|
+
try {
|
|
156
|
+
for (const [shape, env, named] of [['git missing from PATH', withoutGit(root), /ENOENT/], ['GIT_DIR naming nothing', brokenGitDir(root), /not a git repository: /]]) {
|
|
157
|
+
assert.match(skillsSectionAt(root, env).gitError || '', named, `${shape}: the model names the git error`);
|
|
158
|
+
const check = genAt(root, ['--check'], env);
|
|
159
|
+
assert.equal(check.code, 2, `${shape}: --check must fail rather than pass on an unfiltered listing`);
|
|
160
|
+
assert.match(check.stderr, /gen-file-map --check: git could not list tracked files/, shape);
|
|
161
|
+
const write = genAt(root, [], env);
|
|
162
|
+
assert.equal(write.code, 2, `${shape}: write mode exits non-zero`);
|
|
163
|
+
assert.match(write.stderr, /gen-file-map: WARN git could not list tracked files/, shape);
|
|
164
|
+
}
|
|
165
|
+
// The same root with git answering carries no git error: the failure above is the env, not the fixture.
|
|
166
|
+
assert.equal(skillsSectionAt(root).gitError, null);
|
|
167
|
+
} finally {
|
|
168
|
+
rmSync(root, { recursive: true, force: true });
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
140
172
|
test('generation is deterministic + idempotent (a second run is a no-op)', () => {
|
|
141
173
|
const r1 = run([]);
|
|
142
174
|
assert.equal(r1.code, 0);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// tests/sky_mine.mjs — GET /sky/mine, the caller's whole trail in one read (task
|
|
2
|
+
// 1004230, sky part 2 of 5; goal 1000095; ADR 0331, ADR 0322).
|
|
3
|
+
//
|
|
4
|
+
// What this file pins: that the rows are the trail's own (the same list read,
|
|
5
|
+
// the same three attachers, in the trail route's order — nothing derived here);
|
|
6
|
+
// that the cap holds at a thousand and says so; that the builder is the
|
|
7
|
+
// signed-in caller and never a URL parameter; and that the route is mounted
|
|
8
|
+
// beside, not under, the lifecycle module's /sky.
|
|
9
|
+
//
|
|
10
|
+
// DB-free: the read is driven with stub pages and stub attachers; the handler
|
|
11
|
+
// with a stub read; the mount is read from the router and the manifest.
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/sky_mine.mjs
|
|
14
|
+
process.env.NODE_ENV = 'test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { makeRunner } from './helpers.mjs';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const { readSkyMine, skyMineHandler, SKY_MINE_CAP } = require(path.join(ROOT, 'modules/ideas/sky-mine.js'));
|
|
25
|
+
const inbox = require(path.join(ROOT, 'modules/ideas/inbox.js'));
|
|
26
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/ideas/module.json'), 'utf8'));
|
|
27
|
+
const LIB_SRC = fs.readFileSync(path.join(ROOT, 'modules/ideas/sky-mine.js'), 'utf8');
|
|
28
|
+
const ROUTE_SRC = fs.readFileSync(path.join(ROOT, 'modules/ideas/routes/sky-mine.js'), 'utf8');
|
|
29
|
+
const TRAIL_ROUTE_SRC = fs.readFileSync(path.join(ROOT, 'modules/ideas/routes/inbox.js'), 'utf8');
|
|
30
|
+
|
|
31
|
+
const { test, summary } = makeRunner();
|
|
32
|
+
|
|
33
|
+
// A stub trail of N ideas, paged exactly as listIdeaTrailForBuilder pages.
|
|
34
|
+
function stubTrail(n) {
|
|
35
|
+
const all = Array.from({ length: n }, (_, i) => ({ id: String(n - i), title: `Idea ${n - i}`, status: 'open' }));
|
|
36
|
+
const calls = { list: [], count: [], attach: [] };
|
|
37
|
+
const deps = {
|
|
38
|
+
listIdeaTrailForBuilder: async (builderId, { limit, offset, viewerRank }) => { calls.list.push({ builderId, limit, offset, viewerRank }); return all.slice(offset, offset + limit); },
|
|
39
|
+
countIdeaTrailForBuilder: async (builderId, { viewerRank }) => { calls.count.push({ builderId, viewerRank }); return n; },
|
|
40
|
+
attachDescentToTrail: async (ideas) => { calls.attach.push(['descent', ideas.length]); for (const i of ideas) i.descent_tasks = []; },
|
|
41
|
+
attachSizeToTrail: async (ideas) => { calls.attach.push(['size', ideas.length]); for (const i of ideas) i.size = { credits: 0, scale: 0 }; },
|
|
42
|
+
attachStateToTrail: async (ideas) => { calls.attach.push(['state', ideas.length]); for (const i of ideas) i.state = { object: 'protostar' }; },
|
|
43
|
+
};
|
|
44
|
+
return { deps, calls };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
await test('the read pages the trail\'s own list at its own page size until the total, and returns every row', async () => {
|
|
48
|
+
const { deps, calls } = stubTrail(249);
|
|
49
|
+
const out = await readSkyMine({ builderId: 9, viewerRank: 'metic' }, deps);
|
|
50
|
+
assert.equal(out.ideas.length, 249);
|
|
51
|
+
assert.equal(out.total, 249);
|
|
52
|
+
assert.equal(out.truncated, false);
|
|
53
|
+
assert.equal(out.cap, SKY_MINE_CAP);
|
|
54
|
+
assert.equal(SKY_MINE_CAP, 1000);
|
|
55
|
+
assert.deepEqual(calls.list.map((c) => [c.limit, c.offset]), [[100, 0], [100, 100], [100, 200]], 'three pages of the trail\'s max page size');
|
|
56
|
+
assert.equal(calls.list[0].limit, inbox.IDEA_TRAIL_MAX_LIMIT, 'the page size is the trail\'s, not a number of its own');
|
|
57
|
+
for (const c of calls.list) { assert.equal(c.builderId, 9); assert.equal(c.viewerRank, 'metic', 'the viewer\'s rank rides into every page'); }
|
|
58
|
+
assert.equal(out.ideas[0].id, '249', 'newest first, as the trail orders');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await test('the three attachers run ONCE over the whole set, in the trail route\'s order: descent, then size, then state', async () => {
|
|
62
|
+
const { deps, calls } = stubTrail(150);
|
|
63
|
+
const out = await readSkyMine({ builderId: 9 }, deps);
|
|
64
|
+
assert.deepEqual(calls.attach, [['descent', 150], ['size', 150], ['state', 150]]);
|
|
65
|
+
assert.ok(out.ideas.every((i) => i.state && i.size && Array.isArray(i.descent_tasks)));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await test('the cap holds at 1000: a filer past it gets the newest thousand and truncated: true, never a silent slice', async () => {
|
|
69
|
+
const { deps, calls } = stubTrail(1234);
|
|
70
|
+
const out = await readSkyMine({ builderId: 9 }, deps);
|
|
71
|
+
assert.equal(out.ideas.length, 1000);
|
|
72
|
+
assert.equal(out.total, 1234);
|
|
73
|
+
assert.equal(out.truncated, true);
|
|
74
|
+
assert.equal(calls.list.length, 10, 'ten pages, not thirteen');
|
|
75
|
+
assert.equal(calls.list[9].offset, 900);
|
|
76
|
+
assert.equal(calls.list[9].limit, 100);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await test('an odd cap asks the last page for only what is left', async () => {
|
|
80
|
+
const { deps, calls } = stubTrail(500);
|
|
81
|
+
const out = await readSkyMine({ builderId: 9 }, { ...deps, cap: 250 });
|
|
82
|
+
assert.equal(out.ideas.length, 250);
|
|
83
|
+
assert.deepEqual(calls.list.map((c) => [c.limit, c.offset]), [[100, 0], [100, 100], [50, 200]]);
|
|
84
|
+
assert.equal(out.truncated, true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await test('a short page ends the walk early (the count moved under us), and an empty trail reads nothing', async () => {
|
|
88
|
+
const { deps, calls } = stubTrail(0);
|
|
89
|
+
const out = await readSkyMine({ builderId: 9 }, deps);
|
|
90
|
+
assert.deepEqual(out.ideas, []);
|
|
91
|
+
assert.equal(calls.list.length, 0, 'no page is read for a total of zero');
|
|
92
|
+
assert.equal(out.truncated, false);
|
|
93
|
+
|
|
94
|
+
const { deps: d2, calls: c2 } = stubTrail(150);
|
|
95
|
+
d2.countIdeaTrailForBuilder = async () => 400; // the count claims more than the list has
|
|
96
|
+
const out2 = await readSkyMine({ builderId: 9 }, d2);
|
|
97
|
+
assert.equal(out2.ideas.length, 150);
|
|
98
|
+
assert.equal(c2.list.length, 2, 'stops on the short page rather than reading empties');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await test('nothing is derived here — the lib calls the trail\'s own read and the three attachers, and never touches the pool or idea-state internals', () => {
|
|
102
|
+
assert.match(LIB_SRC, /require\('\.\/inbox'\)/);
|
|
103
|
+
assert.match(LIB_SRC, /attachDescentToTrail/);
|
|
104
|
+
assert.match(LIB_SRC, /attachSizeToTrail/);
|
|
105
|
+
assert.match(LIB_SRC, /attachStateToTrail/);
|
|
106
|
+
assert.equal(/pool\.query|resolveIdeaState|SELECT /.test(LIB_SRC), false, 'no SQL and no second derivation (ADR 0322)');
|
|
107
|
+
// and the order matches the trail route's, read from its source
|
|
108
|
+
const order = (src) => ['attachDescentToTrail(', 'attachSizeToTrail(', 'attachStateToTrail('].map((n) => src.indexOf(n));
|
|
109
|
+
const trailOrder = order(TRAIL_ROUTE_SRC.slice(TRAIL_ROUTE_SRC.indexOf("'/inbox/by-builder/:id'")));
|
|
110
|
+
assert.ok(trailOrder[0] < trailOrder[1] && trailOrder[1] < trailOrder[2], 'the trail route runs descent, size, state');
|
|
111
|
+
const mine = LIB_SRC.slice(LIB_SRC.indexOf('await descent('));
|
|
112
|
+
assert.ok(mine.indexOf('await descent(') < mine.indexOf('await size(') && mine.indexOf('await size(') < mine.indexOf('await state('));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ---- the handler ----------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
await test('the builder is the signed-in caller — from the request, never a param or the query', async () => {
|
|
118
|
+
let seen = null;
|
|
119
|
+
const h = skyMineHandler({ read: async (o) => { seen = o; return { builder_id: o.builderId, ideas: [], total: 0, cap: 1000, truncated: false }; } });
|
|
120
|
+
let body = null;
|
|
121
|
+
await h({ builder: { id: '41', rank: 'thetes' }, params: { id: '999' }, query: { builder_id: '998' } }, { json(b) { body = b; } });
|
|
122
|
+
assert.deepEqual(seen, { builderId: 41, viewerRank: 'thetes' });
|
|
123
|
+
assert.equal(body.builder_id, 41);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ---- the mount ------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
await test('the route is contributed by the ideas manifest at /sky/mine, gated to a signed-in builder, with no :id under /sky', () => {
|
|
129
|
+
assert.ok(manifest.contributes.routes.includes('sky-mine'), 'module.json contributes "sky-mine"');
|
|
130
|
+
assert.match(ROUTE_SRC, /router\.get\('\/sky\/mine',\s*api\.requireBuilder,/);
|
|
131
|
+
assert.equal(/\/sky\/:/.test(ROUTE_SRC), false, 'no parameterised /sky/:x that could bind the literal "mine"');
|
|
132
|
+
const factory = require(path.join(ROOT, 'modules/ideas/routes/sky-mine.js'));
|
|
133
|
+
const router = factory({});
|
|
134
|
+
const paths = router.stack.filter((l) => l.route).map((l) => l.route.path);
|
|
135
|
+
assert.deepEqual(paths, ['/sky/mine']);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
summary();
|