@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.
@@ -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.1010",
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.1010",
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.1010",
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): nothing to ask.
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
- const git = (args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
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 new Set(git(['ls-files', '-z', '--', relDir]).split('\0')
100
- .map((p) => p.slice(relDir.length + 1).split('/'))
101
- .filter((segs) => segs.length > 1)
102
- .map((segs) => segs[0]));
103
- } catch { return null; }
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.1010'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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');
@@ -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();