peaks-loop 4.0.51 → 4.0.52

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/baseline-commands.js +11 -1
  5. package/dist/cli/commands/codegraph-command-runtime.d.ts +28 -0
  6. package/dist/cli/commands/codegraph-command-runtime.js +72 -0
  7. package/dist/cli/commands/codegraph-commands.d.ts +2 -11
  8. package/dist/cli/commands/codegraph-commands.js +173 -228
  9. package/dist/cli/commands/codegraph-status-command.d.ts +22 -0
  10. package/dist/cli/commands/codegraph-status-command.js +299 -0
  11. package/dist/cli/commands/core/memory-command.js +6 -2
  12. package/dist/cli/commands/job-commands.js +121 -30
  13. package/dist/cli/commands/project-commands.js +13 -3
  14. package/dist/cli/commands/request-commands.js +19 -8
  15. package/dist/cli/commands/slice-commands.js +2 -2
  16. package/dist/services/artifacts/artifact-prerequisites.js +23 -1
  17. package/dist/services/codegraph/codegraph-autorefresh.d.ts +16 -0
  18. package/dist/services/codegraph/codegraph-autorefresh.js +51 -5
  19. package/dist/services/codegraph/codegraph-config-repair-writer.d.ts +88 -0
  20. package/dist/services/codegraph/codegraph-config-repair-writer.js +322 -0
  21. package/dist/services/codegraph/codegraph-exclude-integrity.d.ts +20 -2
  22. package/dist/services/codegraph/codegraph-exclude-integrity.js +24 -3
  23. package/dist/services/codegraph/codegraph-exclude-reconciler.d.ts +23 -2
  24. package/dist/services/codegraph/codegraph-exclude-reconciler.js +123 -12
  25. package/dist/services/codegraph/codegraph-exclude-repair.d.ts +109 -55
  26. package/dist/services/codegraph/codegraph-exclude-repair.js +249 -195
  27. package/dist/services/codegraph/codegraph-include-reconciler.d.ts +10 -0
  28. package/dist/services/codegraph/codegraph-include-reconciler.js +160 -0
  29. package/dist/services/codegraph/codegraph-index-integrity.d.ts +268 -0
  30. package/dist/services/codegraph/codegraph-index-integrity.js +471 -0
  31. package/dist/services/codegraph/codegraph-service.d.ts +54 -0
  32. package/dist/services/codegraph/codegraph-service.js +84 -1
  33. package/dist/services/doctor/doctor-service/checks/codegraph-exclude-integrity.js +19 -4
  34. package/dist/services/doctor/doctor-service/checks/codegraph-index-integrity.d.ts +54 -0
  35. package/dist/services/doctor/doctor-service/checks/codegraph-index-integrity.js +151 -0
  36. package/dist/services/doctor/doctor-service/checks/l3-orphan-sessions.js +10 -10
  37. package/dist/services/doctor/doctor-service/plugin-registry.js +2 -0
  38. package/dist/services/doctor/doctor-service/types.d.ts +25 -0
  39. package/dist/services/memory/project-memory-service/index/kind-dispatch.js +48 -13
  40. package/dist/services/memory/project-memory-service/index.d.ts +5 -3
  41. package/dist/services/memory/project-memory-service/index.js +2 -2
  42. package/dist/services/memory/project-memory-service/parsers/frontmatter.d.ts +15 -1
  43. package/dist/services/memory/project-memory-service/parsers/frontmatter.js +34 -6
  44. package/dist/services/memory/project-memory-service/parsers/markdown-pure.d.ts +27 -1
  45. package/dist/services/memory/project-memory-service/parsers/markdown-pure.js +92 -7
  46. package/dist/services/memory/project-memory-service/types.d.ts +86 -0
  47. package/dist/services/slice/slice-check-types.d.ts +1 -1
  48. package/dist/services/workspace/runtime-layout.d.ts +91 -0
  49. package/dist/services/workspace/runtime-layout.js +148 -0
  50. package/dist/services/workspace/workspace-claude-settings-materializer.js +14 -0
  51. package/package.json +6 -6
  52. package/scripts/clean-dist.mjs +15 -3
  53. package/scripts/sync-version.mjs +26 -4
  54. package/skills/bee/peaks-prd/SKILL.md +1 -1
  55. package/skills/bee/peaks-qa/references/qa-skill-presence.md +1 -1
  56. package/skills/bee/peaks-rd/references/skill-presence-and-title.md +1 -1
  57. package/skills/bee/peaks-sc/SKILL.md +1 -1
  58. package/skills/bee/peaks-txt/SKILL.md +3 -3
  59. package/skills/peaks-code/SKILL.md +1 -1
  60. package/skills/peaks-code/references/project-memory-loading.md +19 -1
  61. package/skills/peaks-code/references/step-11-memory-sediment.md +1 -1
@@ -0,0 +1,322 @@
1
+ // src/services/codegraph/codegraph-config-repair-writer.ts
2
+ //
3
+ // The pure repair plans (`repairCodegraphExclude` for the exclude axis,
4
+ // `repairCodegraphInclude` for the include axis) and the writer that applies
5
+ // them to `<projectRoot>/.codegraph/config.json` in ONE atomic rewrite, plus
6
+ // the byte-exact `config.json.bak` copy it keeps for rollback.
7
+ //
8
+ // Extracted verbatim from `codegraph-exclude-repair.ts` (rid
9
+ // 2026-09-17-oversize-followup, G1 — the 800-line file-size cap). Every moved
10
+ // line is byte-identical and no behaviour changed; `codegraph-exclude-repair.ts`
11
+ // re-exports this module's public surface, so every existing import site
12
+ // (`applyCodegraphConfigRepair`, `repairCodegraphExclude`,
13
+ // `repairCodegraphInclude`, `CODEGRAPH_CONFIG_BACKUP_SUFFIX`, and the three
14
+ // plan/outcome types) keeps working unchanged.
15
+ //
16
+ // The dependency edge is ONE-WAY and must stay that way: this module must not
17
+ // import `codegraph-exclude-repair.ts`, which imports this one.
18
+ import { randomBytes } from 'node:crypto';
19
+ import { chmodSync, lstatSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { assertStringArray, CODEGRAPH_CONFIG_FILENAME } from './codegraph-exclude-reconciler.js';
22
+ import { assertCodegraphDirContained, CODEGRAPH_DIR_NAME } from './codegraph-service.js';
23
+ /** Suffix of the byte-exact pre-repair copy kept next to the config. */
24
+ export const CODEGRAPH_CONFIG_BACKUP_SUFFIX = '.bak';
25
+ /**
26
+ * Pure: given the current `exclude` list and the rules to drop, return
27
+ * the new list. No fs, no clock, no serialization.
28
+ *
29
+ * A rule named in `rulesToRemove` but absent from `exclude` is NOT
30
+ * invented — the result is a subset of the input, so a caller can
31
+ * never add a rule by accident. Removing an already-absent rule is a
32
+ * no-op, which is what makes the whole repair idempotent: feeding the
33
+ * repaired list back in yields `changed: false`.
34
+ */
35
+ export function repairCodegraphExclude(input) {
36
+ const removable = new Set(input.rulesToRemove);
37
+ // De-duplicated, order-preserving. A config that lists the same rule
38
+ // twice would otherwise be counted twice here, and this array is what
39
+ // the caller reports to the user as "rules removed".
40
+ const removedRules = [...new Set(input.exclude.filter((rule) => removable.has(rule)))];
41
+ if (removedRules.length === 0) {
42
+ return { changed: false, exclude: input.exclude, removedRules: [] };
43
+ }
44
+ return {
45
+ changed: true,
46
+ exclude: input.exclude.filter((rule) => !removable.has(rule)),
47
+ removedRules
48
+ };
49
+ }
50
+ /**
51
+ * Pure: given the current `include` list and the patterns to append, return
52
+ * the new list. The mirror of `repairCodegraphExclude`, in the other
53
+ * direction — a SUPERSET operation instead of a subset one.
54
+ *
55
+ * A pattern already present is not appended twice: the include reconciler
56
+ * already guarantees that, but this function is the writer's own last line
57
+ * of defence, and a duplicated glob in a third-party config would be a
58
+ * visible defect even though it changes no matching behaviour.
59
+ */
60
+ export function repairCodegraphInclude(input) {
61
+ const existing = new Set(input.include);
62
+ const additions = input.patternsToAdd.filter((pattern) => !existing.has(pattern));
63
+ if (additions.length === 0) {
64
+ return { changed: false, include: input.include, addedPatterns: [] };
65
+ }
66
+ return { changed: true, include: [...input.include, ...additions], addedPatterns: additions };
67
+ }
68
+ /**
69
+ * Detect the indentation the config already uses so the rewrite keeps
70
+ * the file's shape instead of reformatting a third-party tool's file.
71
+ *
72
+ * A single-line (minified) config has no indentation to copy: `0` tells
73
+ * `JSON.stringify` to emit compact JSON, so the file comes back as the
74
+ * one-liner it went in as. `0` is NOT the same as "not set" here — an
75
+ * omitted indent would also be compact, but returning a number keeps the
76
+ * intent explicit at the call site.
77
+ *
78
+ * Falls back to two spaces when the file spans lines but has no indented
79
+ * member.
80
+ */
81
+ function detectIndent(text) {
82
+ if (!text.trimEnd().includes('\n')) {
83
+ return 0;
84
+ }
85
+ const match = /\n([ \t]+)"/.exec(text);
86
+ return match?.[1] ?? 2;
87
+ }
88
+ function serializeConfig(config, originalText) {
89
+ const body = JSON.stringify(config, null, detectIndent(originalText));
90
+ return originalText.endsWith('\n') ? `${body}\n` : body;
91
+ }
92
+ /**
93
+ * Write `content` to `filePath` atomically: same-directory temp file,
94
+ * then `renameSync` over the target.
95
+ *
96
+ * The file being written belongs to a THIRD-PARTY tool, so a crash or a
97
+ * full disk mid-`writeFileSync` must never leave a half-written config
98
+ * behind. `rename` within one directory is atomic, so a reader sees
99
+ * either the old bytes or the new ones, never a prefix of the new ones.
100
+ * The temp file lives next to the target (same directory ⇒ same
101
+ * filesystem ⇒ the rename cannot degrade to a cross-device copy) and is
102
+ * removed if the write or the rename fails.
103
+ *
104
+ * N5: the temp name carries the pid plus 6 random bytes. A FIXED
105
+ * `${filePath}.tmp` is single-writer only, and this writer has three
106
+ * reachable concurrent callers — a fresh `peaks codegraph init` (which
107
+ * repairs then indexes), the pre-dispatch preflight, and the post-slice
108
+ * autorefresh. Two overlapping writers sharing one temp name let one
109
+ * `renameSync` publish a file the other was still writing, which is
110
+ * exactly the half-written config the temp file exists to prevent. The
111
+ * suffix keeps the temp in the SAME directory, so the rename stays
112
+ * within one filesystem and therefore stays atomic.
113
+ */
114
+ function writeConfigAtomic(filePath, content, mode) {
115
+ const tempPath = `${filePath}.${String(process.pid)}.${randomBytes(6).toString('hex')}.tmp`;
116
+ try {
117
+ writeFileSync(tempPath, content, 'utf8');
118
+ if (mode !== undefined) {
119
+ // The mode is applied to the TEMP, before the rename, so the
120
+ // published file has it in the same atomic step — a `chmod` after
121
+ // the rename would leave a window in which the file exists at the
122
+ // process umask instead of the original's mode.
123
+ chmodSync(tempPath, mode);
124
+ // Windows cannot REPLACE a read-only destination: `renameSync` over
125
+ // one throws EPERM (measured, `2026-09-17-codegraph-msg-and-refresh`).
126
+ // The only destination this branch ever sees read-only is this
127
+ // writer's own previous `.bak`, which under A4 carries the original
128
+ // config's mode — so a read-only config would make the SECOND repair
129
+ // fail at the rename. Clear the bit and let the rename plus the mode
130
+ // above put it back. A destination that is not a regular file is left
131
+ // alone: `writeConfigBackup`'s link/directory guard has already
132
+ // refused those before this function is reached.
133
+ const existing = lstatSync(filePath, { throwIfNoEntry: false });
134
+ if (existing !== undefined && existing.isFile() && (existing.mode & 0o200) === 0) {
135
+ chmodSync(filePath, existing.mode | 0o200);
136
+ }
137
+ }
138
+ renameSync(tempPath, filePath);
139
+ }
140
+ catch (error) {
141
+ rmSync(tempPath, { force: true });
142
+ throw error;
143
+ }
144
+ }
145
+ /**
146
+ * Copy the config's ORIGINAL bytes to `config.json.bak`, refusing to write
147
+ * through a link that already occupies that path.
148
+ *
149
+ * The backup path is FIXED (`<configPath>.bak`) and therefore guessable, and
150
+ * `.codegraph/config.json.bak` is committable in a consumer project. So a
151
+ * repository can ship that path as a link to an arbitrary file of the
152
+ * attacker's choosing plus a `config.json` that merely OMITS an extension —
153
+ * which is the DEFAULT state of every config written before the include axis
154
+ * existed — and any repair-seam run (a fresh `peaks codegraph init`, the
155
+ * pre-dispatch preflight, the post-slice autorefresh, or either explicit
156
+ * repair verb) would write the whole attacker-authored config file through
157
+ * that link. Arbitrary file overwrite, as the invoking user, with no
158
+ * operator action. `writeFileSync`'s default `'w'` flag is
159
+ * `O_WRONLY|O_CREAT|O_TRUNC`: it follows a symlink and it truncates a hard
160
+ * link's shared inode, so it is the wrong primitive here.
161
+ *
162
+ * Two independent refusals, because either one alone leaves a case open:
163
+ *
164
+ * 1. `lstat` (never `stat`) rejects a SYMBOLIC LINK — `stat` would follow
165
+ * it and report the victim's regular-file type — and a HARD LINK, which
166
+ * `lstat` cannot distinguish by type but the link count exposes
167
+ * (`nlink > 1` means another name shares this inode, so writing here
168
+ * writes to that file too). Both are refusals, not repairs: silently
169
+ * replacing someone else's link is not this writer's decision to make.
170
+ * 2. The copy goes through `writeConfigAtomic` (CSPRNG temp + `renameSync`),
171
+ * so even an entry that appears between the `lstat` and the write is
172
+ * REPLACED rather than written through — `rename` never follows the
173
+ * destination's link, and it never truncates the destination's inode.
174
+ * That also makes the backup atomic, which the module header's
175
+ * byte-exact-rollback promise needs and a bare `writeFileSync` did not
176
+ * provide.
177
+ *
178
+ * What is NOT refused: a regular file at the backup path with a link count
179
+ * of 1. That is this writer's own previous backup (a project may legitimately
180
+ * drift and be repaired twice), and replacing it atomically is exactly what
181
+ * the `rename` in (2) does. `O_EXCL` alone would have refused that legitimate
182
+ * case along with the attack, which is why the guard is a link test rather
183
+ * than an existence test.
184
+ *
185
+ * `writeFileSync` there is called only after parsing and planning have both
186
+ * succeeded, so a refusal leaves the config BYTES UNTOUCHED — the throw
187
+ * propagates out of `applyCodegraphConfigRepair` before the rewrite.
188
+ *
189
+ * A4 (`2026-09-17-codegraph-msg-and-refresh`, the L1 half left over from
190
+ * slice-002's S1): the copy carries the ORIGINAL CONFIG'S MODE, because the
191
+ * mode is part of what a rollback restores. The `.bak` exists so an operator
192
+ * can put the previous config back with a `mv`, and a restore that hands
193
+ * back a file whose permissions were decided by this process's umask is not
194
+ * a restore: a `0600` config would come back as `0644` — group- and
195
+ * world-readable, a permission the project never granted it.
196
+ *
197
+ * The mode is read with `statSync` on the config (not `lstatSync`, and not
198
+ * from the caller): the caller has just read that file's bytes, so it
199
+ * exists, and a config that is itself a symlink is a case upstream's own
200
+ * reader follows — the permissions that matter are the ones the operator
201
+ * sees on the config.
202
+ *
203
+ * NOT applied to the config rewrite beside it, deliberately: that path
204
+ * (`writeConfigAtomic(configPath, …)` with no mode) keeps the process
205
+ * default, which is what upstream's own `init` produces. Stated rather than
206
+ * left silent — see this batch's RD artifact, section A4.
207
+ */
208
+ function writeConfigBackup(configPath, originalText) {
209
+ const backupPath = `${configPath}${CODEGRAPH_CONFIG_BACKUP_SUFFIX}`;
210
+ const existing = lstatSync(backupPath, { throwIfNoEntry: false });
211
+ if (existing !== undefined &&
212
+ (existing.isSymbolicLink() || existing.isDirectory() || existing.nlink > 1)) {
213
+ const kind = existing.isSymbolicLink()
214
+ ? 'a symbolic link'
215
+ : existing.isDirectory()
216
+ ? 'a directory'
217
+ : `a hard link (link count ${String(existing.nlink)})`;
218
+ // "through" for a link (the bytes land in whatever it points at), "at" for
219
+ // a directory (there is nowhere for them to land).
220
+ const preposition = existing.isDirectory() ? 'at' : 'through';
221
+ throw new Error(`codegraph config backup ${backupPath}: refusing to write ${preposition} ${kind} occupying ` +
222
+ 'this path. Another file or directory shares it, so a backup written here would overwrite ' +
223
+ 'that. Remove it (or point `peaks` at a project root whose `.codegraph/` it owns) and re-run.');
224
+ }
225
+ // A4: the ORIGINAL config's mode travels with the copy. `& 0o777` drops
226
+ // the file-type bits `statSync` packs above the permission bits — `chmod`
227
+ // takes permission bits only.
228
+ writeConfigAtomic(backupPath, originalText, statSync(configPath).mode & 0o777);
229
+ return backupPath;
230
+ }
231
+ /**
232
+ * Apply BOTH config repairs to `<projectRoot>/.codegraph/config.json` in one
233
+ * atomic rewrite.
234
+ *
235
+ * No-op (and no write, no mtime change, no backup) when both lists are
236
+ * empty. Otherwise: back up the original bytes to `config.json.bak`, then
237
+ * rewrite the file with `exclude` reduced by exactly the rules that were
238
+ * both requested and present, and `include` extended by exactly the
239
+ * patterns that were both requested and absent.
240
+ *
241
+ * ONE rewrite, not two. A caller that widened `include` in one write and
242
+ * dropped the newly-offending `exclude` rules in a second would leave a
243
+ * window in which the config on disk is worse than it started (the widened
244
+ * include admits a file that a surviving rule then hides from the index),
245
+ * and would need two backups to stay rollback-exact. One rewrite through
246
+ * the same-directory temp file has neither property.
247
+ *
248
+ * Throws only on real fs/parse failures and on the containment refusal
249
+ * above — the caller decides whether that is fatal (`repair-exclude` /
250
+ * `repair-index` → non-zero exit) or a surfaced warning (`init` → keep
251
+ * going, the init itself already succeeded). Both are refusals BEFORE
252
+ * anything is written, so a throw can never half-apply.
253
+ */
254
+ export function applyCodegraphConfigRepair(projectRoot, repair) {
255
+ if (repair.rulesToRemove.length === 0 && repair.includePatternsToAdd.length === 0) {
256
+ return {
257
+ applied: false,
258
+ reason: 'nothing-to-repair',
259
+ removedRules: [],
260
+ addedIncludePatterns: []
261
+ };
262
+ }
263
+ // Containment FIRST — before even reading (S12 / security R1). The `.bak`
264
+ // guard below protects a FILE path; this protects the DIRECTORY it lives
265
+ // in, so `<root>/.codegraph` as a junction cannot redirect the rewrite (or
266
+ // the parsed read) into another project. Called for its refusal only: the
267
+ // write target stays derived from the caller's `projectRoot`, so this
268
+ // verb's canonicalization remains where L2/S5 put it, at the CLI edge.
269
+ assertCodegraphDirContained(projectRoot);
270
+ const configPath = join(projectRoot, CODEGRAPH_DIR_NAME, CODEGRAPH_CONFIG_FILENAME);
271
+ const originalText = readFileSync(configPath, 'utf8');
272
+ const parsed = JSON.parse(originalText);
273
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
274
+ throw new Error(`codegraph config ${configPath}: expected a JSON object`);
275
+ }
276
+ const record = parsed;
277
+ const exclude = assertStringArray(record.exclude, 'exclude', configPath);
278
+ // An ABSENT `include` key is tolerated, and is NOT the same as a malformed
279
+ // one: this writer predates the include axis and callers exist whose
280
+ // config carries only `exclude` (a hand-written minimal file; the S2
281
+ // hardening fixtures are exactly that). Throwing here would make the
282
+ // whole repair fail — including the exclude half that used to succeed —
283
+ // so the include axis simply has nothing to extend, and the key is never
284
+ // invented. A key that is PRESENT but is not an array of strings is still
285
+ // an error: that is a genuinely malformed config, and silently ignoring it
286
+ // is how a wrong `include` would survive a repair that reported success.
287
+ const include = record.include === undefined ? [] : assertStringArray(record.include, 'include', configPath);
288
+ const excludePlan = repairCodegraphExclude({ exclude, rulesToRemove: repair.rulesToRemove });
289
+ const includePlan = repairCodegraphInclude({ include, patternsToAdd: repair.includePatternsToAdd });
290
+ if (!excludePlan.changed && !includePlan.changed) {
291
+ return {
292
+ applied: false,
293
+ reason: 'nothing-to-repair',
294
+ removedRules: [],
295
+ addedIncludePatterns: []
296
+ };
297
+ }
298
+ const backupPath = writeConfigBackup(configPath, originalText);
299
+ // Spread first, then replace the keys that changed — every other key keeps
300
+ // its original value AND its original position in the serialized object.
301
+ // `include` is only written back when it actually changed, so a config
302
+ // without that key does not gain one from a repair that did not touch it.
303
+ const nextRecord = { ...record, exclude: excludePlan.exclude };
304
+ if (includePlan.changed) {
305
+ nextRecord.include = includePlan.include;
306
+ }
307
+ writeConfigAtomic(configPath, serializeConfig(nextRecord, originalText));
308
+ return {
309
+ applied: true,
310
+ configPath,
311
+ backupPath,
312
+ removedRules: excludePlan.removedRules,
313
+ // The patterns that ACTUALLY landed (a caller-supplied pattern that was
314
+ // already present is not an addition), so the report cannot overstate
315
+ // what changed on disk.
316
+ addedIncludePatterns: includePlan.addedPatterns,
317
+ excludeCountBefore: exclude.length,
318
+ excludeCountAfter: excludePlan.exclude.length,
319
+ includeCountBefore: include.length,
320
+ includeCountAfter: includePlan.include.length
321
+ };
322
+ }
@@ -1,4 +1,4 @@
1
- import { type CodegraphExcludeViolation } from './codegraph-exclude-reconciler.js';
1
+ import { type CodegraphExcludeViolation, type ReadCodegraphExcludeConfig, type ReadTrackedFiles } from './codegraph-exclude-reconciler.js';
2
2
  /**
3
3
  * Exit code `peaks codegraph status` uses when the index is
4
4
  * demonstrably incomplete. Distinct from the upstream pass-through
@@ -48,8 +48,26 @@ export declare function isCodegraphExcludeConfigPresent(projectRoot: string): bo
48
48
  * Throws (never silently degrades) when the project is not a git work
49
49
  * tree, when the config is missing, or when it is malformed — callers
50
50
  * that must stay alive (doctor, `status`) catch and surface the reason.
51
+ *
52
+ * `inputs` is the optional shared-read seam (perf audit F1): a caller
53
+ * that runs BOTH codegraph axes in one process reads
54
+ * `readCodegraphProjectInputs(projectRoot)` once and passes it here and
55
+ * to `inspectCodegraphIndexIntegrity`, so the `git ls-files` spawn, the
56
+ * config read and the `include` glob compilation happen once instead of
57
+ * twice. Omitted (the default) the readers run exactly as they always
58
+ * did, so this is additive for every existing caller.
59
+ *
60
+ * Both fields are read-marked (code review R4-1): a caller may hand over a
61
+ * value that came out of the readers, never one it built itself. An
62
+ * explicit `[]` used to be accepted and silently meant "nothing is
63
+ * tracked", turning a real gap into a clean report; it is now a compile
64
+ * error, and a run-time throw for a caller the type system cannot see.
65
+ * `undefined` still means "read it yourself" — unchanged.
51
66
  */
52
- export declare function inspectCodegraphExcludeIntegrity(projectRoot: string): CodegraphExcludeIntegrityReport;
67
+ export declare function inspectCodegraphExcludeIntegrity(projectRoot: string, inputs?: {
68
+ readonly trackedFiles?: ReadTrackedFiles | undefined;
69
+ readonly config?: ReadCodegraphExcludeConfig | undefined;
70
+ }): CodegraphExcludeIntegrityReport;
53
71
  /**
54
72
  * Human-readable detail lines for a gapped report: the headline count,
55
73
  * then the offending rules, then a sample of the blocked files so an
@@ -15,7 +15,7 @@
15
15
  // `peaks codegraph repair-exclude` command. Read stays read.
16
16
  import { existsSync } from 'node:fs';
17
17
  import { join } from 'node:path';
18
- import { CODEGRAPH_CONFIG_FILENAME, reconcileCodegraphExcludeFromProject } from './codegraph-exclude-reconciler.js';
18
+ import { CODEGRAPH_CONFIG_FILENAME, resolveSharedConfig, resolveSharedTrackedFiles, reconcileCodegraphExclude } from './codegraph-exclude-reconciler.js';
19
19
  import { CODEGRAPH_DIR_NAME } from './codegraph-service.js';
20
20
  /**
21
21
  * Exit code `peaks codegraph status` uses when the index is
@@ -49,9 +49,30 @@ export function isCodegraphExcludeConfigPresent(projectRoot) {
49
49
  * Throws (never silently degrades) when the project is not a git work
50
50
  * tree, when the config is missing, or when it is malformed — callers
51
51
  * that must stay alive (doctor, `status`) catch and surface the reason.
52
+ *
53
+ * `inputs` is the optional shared-read seam (perf audit F1): a caller
54
+ * that runs BOTH codegraph axes in one process reads
55
+ * `readCodegraphProjectInputs(projectRoot)` once and passes it here and
56
+ * to `inspectCodegraphIndexIntegrity`, so the `git ls-files` spawn, the
57
+ * config read and the `include` glob compilation happen once instead of
58
+ * twice. Omitted (the default) the readers run exactly as they always
59
+ * did, so this is additive for every existing caller.
60
+ *
61
+ * Both fields are read-marked (code review R4-1): a caller may hand over a
62
+ * value that came out of the readers, never one it built itself. An
63
+ * explicit `[]` used to be accepted and silently meant "nothing is
64
+ * tracked", turning a real gap into a clean report; it is now a compile
65
+ * error, and a run-time throw for a caller the type system cannot see.
66
+ * `undefined` still means "read it yourself" — unchanged.
52
67
  */
53
- export function inspectCodegraphExcludeIntegrity(projectRoot) {
54
- const result = reconcileCodegraphExcludeFromProject(projectRoot);
68
+ export function inspectCodegraphExcludeIntegrity(projectRoot, inputs = {}) {
69
+ const trackedFiles = resolveSharedTrackedFiles(inputs.trackedFiles, projectRoot);
70
+ const config = resolveSharedConfig(inputs.config, projectRoot);
71
+ const result = reconcileCodegraphExclude({
72
+ trackedFiles,
73
+ include: config.include,
74
+ exclude: config.exclude
75
+ });
55
76
  const blockedCounts = new Map();
56
77
  for (const violation of result.violations) {
57
78
  blockedCounts.set(violation.matchedRule, (blockedCounts.get(violation.matchedRule) ?? 0) + 1);
@@ -1,4 +1,8 @@
1
1
  export declare function matchesCodegraphGlob(filePath: string, pattern: string): boolean;
2
+ export type CompiledCodegraphGlobs = {
3
+ readonly matchesAny: (filePath: string) => boolean;
4
+ };
5
+ export declare function compileCodegraphGlobs(patterns: readonly string[]): CompiledCodegraphGlobs;
2
6
  export type CodegraphExcludeReconcileInput = {
3
7
  readonly trackedFiles: readonly string[];
4
8
  readonly include: readonly string[];
@@ -14,13 +18,30 @@ export type CodegraphExcludeReconcileResult = {
14
18
  readonly trackedSourceCount: number;
15
19
  readonly excludedTrackedCount: number;
16
20
  };
21
+ export declare function filterAdmittedTrackedFiles(trackedFiles: readonly string[], include: readonly string[]): readonly string[];
17
22
  export declare function reconcileCodegraphExclude(input: CodegraphExcludeReconcileInput): CodegraphExcludeReconcileResult;
18
23
  export declare const CODEGRAPH_CONFIG_FILENAME = "config.json";
19
24
  export type CodegraphExcludeConfig = {
20
25
  readonly include: readonly string[];
21
26
  readonly exclude: readonly string[];
22
27
  };
23
- export declare function readTrackedFiles(projectRoot: string): readonly string[];
28
+ declare const READ_PROVENANCE: unique symbol;
29
+ type ReadProvenance = {
30
+ readonly [READ_PROVENANCE]: true;
31
+ };
32
+ /** A tracked-file list that provably came out of `readTrackedFiles`. */
33
+ export type ReadTrackedFiles = readonly string[] & ReadProvenance;
34
+ /** A config that provably came out of `readCodegraphExcludeConfig`. */
35
+ export type ReadCodegraphExcludeConfig = CodegraphExcludeConfig & ReadProvenance;
36
+ export declare function readTrackedFiles(projectRoot: string): ReadTrackedFiles;
24
37
  export declare function assertStringArray(value: unknown, field: string, configPath: string): readonly string[];
25
- export declare function readCodegraphExcludeConfig(projectRoot: string): CodegraphExcludeConfig;
38
+ export declare function readCodegraphExcludeConfig(projectRoot: string): ReadCodegraphExcludeConfig;
39
+ export type CodegraphProjectInputs = {
40
+ readonly trackedFiles: ReadTrackedFiles;
41
+ readonly config: ReadCodegraphExcludeConfig;
42
+ };
43
+ export declare function readCodegraphProjectInputs(projectRoot: string): CodegraphProjectInputs;
44
+ export declare function resolveSharedTrackedFiles(supplied: ReadTrackedFiles | undefined, projectRoot: string): ReadTrackedFiles;
45
+ export declare function resolveSharedConfig(supplied: ReadCodegraphExcludeConfig | undefined, projectRoot: string): ReadCodegraphExcludeConfig;
26
46
  export declare function reconcileCodegraphExcludeFromProject(projectRoot: string): CodegraphExcludeReconcileResult;
47
+ export {};
@@ -120,6 +120,39 @@ export function matchesCodegraphGlob(filePath, pattern) {
120
120
  }
121
121
  return compileGlob(pattern).match(normalizePath(filePath));
122
122
  }
123
+ export function compileCodegraphGlobs(patterns) {
124
+ const rules = compileRules(patterns);
125
+ return {
126
+ matchesAny: (filePath) => {
127
+ const normalizedPath = normalizePath(filePath);
128
+ return rules.some((rule) => rule.match(normalizedPath));
129
+ }
130
+ };
131
+ }
132
+ // The project-relative paths of the tracked files the config's `include`
133
+ // globs admit, normalized. This is the ONE place the "would the index
134
+ // ingest this path" question is answered, so this reconciler and the
135
+ // index-integrity inspector cannot drift apart on glob semantics: the
136
+ // `include`-axis gap is computed as a set difference against exactly the
137
+ // set this function returns.
138
+ //
139
+ // Pure: no fs, no spawn, no clock. Compiling per rule (not per file x
140
+ // rule) keeps it linear in the number of tracked files.
141
+ //
142
+ // Unmatchable `include` entries are dropped before compiling (see
143
+ // `isUnmatchableRule`). An empty `include` entry admits nothing, which is
144
+ // the same verdict as an explicitly empty `include` list.
145
+ export function filterAdmittedTrackedFiles(trackedFiles, include) {
146
+ const includeRules = compileRules(include);
147
+ const admitted = [];
148
+ for (const candidate of trackedFiles) {
149
+ const normalizedPath = normalizePath(candidate);
150
+ if (includeRules.some((rule) => rule.match(normalizedPath))) {
151
+ admitted.push(normalizedPath);
152
+ }
153
+ }
154
+ return admitted;
155
+ }
123
156
  // Reconcile the codegraph `exclude` list against the set of git-tracked
124
157
  // source files. Pure: no fs, no spawn, no clock.
125
158
  //
@@ -131,15 +164,8 @@ export function reconcileCodegraphExclude(input) {
131
164
  // `isUnmatchableRule`. An empty `include` entry admits nothing, which
132
165
  // is the same verdict as an explicitly empty `include` list, so it
133
166
  // needs no special case here.
134
- const includeRules = compileRules(input.include);
135
167
  const excludeRules = compileRules(input.exclude);
136
- const trackedSourceFiles = [];
137
- for (const candidate of input.trackedFiles) {
138
- const normalizedPath = normalizePath(candidate);
139
- if (includeRules.some((rule) => rule.match(normalizedPath))) {
140
- trackedSourceFiles.push(normalizedPath);
141
- }
142
- }
168
+ const trackedSourceFiles = filterAdmittedTrackedFiles(input.trackedFiles, input.include);
143
169
  const violations = [];
144
170
  const offendingRules = new Set();
145
171
  const blockedFiles = new Set();
@@ -165,6 +191,59 @@ export function reconcileCodegraphExclude(input) {
165
191
  // ─────────────────────────────────────────────────────────────────────
166
192
  // Upstream `CONFIG_FILENAME` inside `<projectRoot>/.codegraph/`.
167
193
  export const CODEGRAPH_CONFIG_FILENAME = 'config.json';
194
+ // ─────────────────────────────────────────────────────────────────────
195
+ // Read provenance — why the shared-input seam refuses a fabricated value
196
+ // ─────────────────────────────────────────────────────────────────────
197
+ // Code review R4-1. The shared-input seam (perf F1) hands an inspector the
198
+ // ALREADY-READ inputs. Before this block, `trackedFiles?: readonly string[]`
199
+ // could not tell "field omitted" (read it yourself) from "field supplied as
200
+ // `[]`" (silently: nothing is tracked) — and the second one turned a real
201
+ // gap into a CLEAN verdict on BOTH axes:
202
+ //
203
+ // E(root, { trackedFiles: [] })
204
+ // exclude axis: gap true -> false, trackedSourceCount 1 -> 0, violations [] -> dropped
205
+ // index axis: includeGap ['scripts/tool.mjs'] -> []
206
+ //
207
+ // That is a silent false pass inside the guard built to prevent silent false
208
+ // passes. Emptiness alone cannot be the discriminator — a repository that
209
+ // genuinely tracks nothing has a legitimately EMPTY list, and rejecting that
210
+ // would turn `peaks codegraph status` on such a repo from "clean" into a
211
+ // spurious warning. Provenance can discriminate, so that is what we key on.
212
+ //
213
+ // Every value this module's readers produce is marked, non-enumerably, with
214
+ // this symbol at the ONE place that owns the readers; the seam accepts only
215
+ // marked values. A hand-built `[]` is therefore unreachable at compile time
216
+ // (the brand is a required property) AND loud at run time (a JS caller or a
217
+ // cast trips the assertion instead of silently reporting clean).
218
+ //
219
+ // `Symbol.for` rather than `Symbol()` so the mark survives the same module
220
+ // being loaded twice (dual ESM/CJS evaluation of a linked package).
221
+ const READ_PROVENANCE = Symbol.for('peaks-loop.codegraph.read-provenance');
222
+ // `enumerable: false` on purpose: the mark is provenance, not content. A
223
+ // `JSON.stringify`, a spread or a deep-equality assertion over the list must
224
+ // not see it, so branding stays invisible to every existing consumer.
225
+ function markAsRead(value) {
226
+ Object.defineProperty(value, READ_PROVENANCE, {
227
+ value: true,
228
+ enumerable: false,
229
+ configurable: false,
230
+ writable: false
231
+ });
232
+ return value;
233
+ }
234
+ // The run-time half of the brand. Reached only by a caller the type system
235
+ // could not police (JS, a cast, or a future `as` in a hurry), so it must be
236
+ // loud and must say what the silent alternative would have done.
237
+ function assertReadProvenance(value, field, producer) {
238
+ if (!Object.prototype.hasOwnProperty.call(value, READ_PROVENANCE)) {
239
+ throw new Error(`codegraph shared inputs: "${field}" did not come from ${producer}(), so it is not a ` +
240
+ 'value this module can trust. A hand-built value is indistinguishable from a real read, ' +
241
+ 'and an empty one means "nothing is tracked / nothing is admitted" — which reports a ' +
242
+ 'real gap as CLEAN on both codegraph axes. Pass the result of ' +
243
+ 'readCodegraphProjectInputs(projectRoot) verbatim, or omit the field to read from disk.');
244
+ }
245
+ return value;
246
+ }
168
247
  // Project-relative paths of every git-tracked file, exactly as
169
248
  // `git ls-files` reports them. Why git and not an fs walk: the index
170
249
  // must cover what git tracks (see the anti-fake-green contract in
@@ -173,17 +252,20 @@ export const CODEGRAPH_CONFIG_FILENAME = 'config.json';
173
252
  //
174
253
  // Throws when `projectRoot` is not inside a git work tree — callers
175
254
  // decide whether that is fatal; this function never swallows it.
255
+ //
256
+ // Returns a READ-MARKED list: the seam helpers below accept only marked
257
+ // values, so a fabricated `[]` cannot be passed off as this read's result.
176
258
  export function readTrackedFiles(projectRoot) {
177
259
  const stdout = execFileSync('git', ['-C', projectRoot, 'ls-files'], {
178
260
  encoding: 'utf8',
179
261
  maxBuffer: 64 * 1024 * 1024,
180
262
  windowsHide: true
181
263
  });
182
- return stdout
264
+ return markAsRead(stdout
183
265
  .split('\n')
184
266
  .map((line) => line.trim())
185
267
  .filter((line) => line.length > 0)
186
- .map((line) => normalizePath(line));
268
+ .map((line) => normalizePath(line)));
187
269
  }
188
270
  // Exported for S2's repair writer, which re-validates `exclude` on the
189
271
  // way out so the read and write paths agree on what a valid config is.
@@ -195,7 +277,7 @@ export function assertStringArray(value, field, configPath) {
195
277
  }
196
278
  // Read `<projectRoot>/.codegraph/config.json` and return just the two
197
279
  // glob lists the reconciler needs. Read-only: this module never writes
198
- // that file.
280
+ // that file. Returns a READ-MARKED config — see `readTrackedFiles`.
199
281
  export function readCodegraphExcludeConfig(projectRoot) {
200
282
  const configPath = join(projectRoot, CODEGRAPH_DIR_NAME, CODEGRAPH_CONFIG_FILENAME);
201
283
  const parsed = JSON.parse(readFileSync(configPath, 'utf8'));
@@ -203,11 +285,40 @@ export function readCodegraphExcludeConfig(projectRoot) {
203
285
  throw new Error(`codegraph config ${configPath}: expected a JSON object`);
204
286
  }
205
287
  const record = parsed;
206
- return {
288
+ return markAsRead({
207
289
  include: assertStringArray(record.include, 'include', configPath),
208
290
  exclude: assertStringArray(record.exclude, 'exclude', configPath)
291
+ });
292
+ }
293
+ // Read both shared inputs exactly once, in the one place that owns the
294
+ // readers. Callers that need both axes (the CLI, and anything added later)
295
+ // should call this and pass the result to each inspector rather than
296
+ // letting each inspector read for itself.
297
+ export function readCodegraphProjectInputs(projectRoot) {
298
+ return {
299
+ trackedFiles: readTrackedFiles(projectRoot),
300
+ config: readCodegraphExcludeConfig(projectRoot)
209
301
  };
210
302
  }
303
+ // ─────────────────────────────────────────────────────────────────────
304
+ // Seam resolution — what each inspector's shared-input seam resolves through
305
+ // ─────────────────────────────────────────────────────────────────────
306
+ // OMITTED (or `undefined`) always means "read it yourself": that is the
307
+ // original, seam-free behaviour and it must stay bit-identical. SUPPLIED
308
+ // means "here is a value you already read" — and the only way to prove that
309
+ // is the read mark, which is why an unmarked value is refused rather than
310
+ // used. An empty marked list is perfectly legal (a repo that tracks nothing
311
+ // really does have none); an empty unmarked one is the false clean.
312
+ export function resolveSharedTrackedFiles(supplied, projectRoot) {
313
+ return supplied === undefined
314
+ ? readTrackedFiles(projectRoot)
315
+ : assertReadProvenance(supplied, 'trackedFiles', 'readTrackedFiles');
316
+ }
317
+ export function resolveSharedConfig(supplied, projectRoot) {
318
+ return supplied === undefined
319
+ ? readCodegraphExcludeConfig(projectRoot)
320
+ : assertReadProvenance(supplied, 'config', 'readCodegraphExcludeConfig');
321
+ }
211
322
  // Read-only entry point: resolve the project's tracked files + codegraph
212
323
  // config from disk and reconcile them. S2's repair path consumes this
213
324
  // result and writes the reduced `exclude` list back; S1 only computes.