peaks-loop 4.0.42 → 4.0.44

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 (76) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/_register.js +4 -0
  5. package/dist/cli/commands/api-diff-commands.d.ts +16 -0
  6. package/dist/cli/commands/api-diff-commands.js +55 -0
  7. package/dist/cli/commands/audit-commands.d.ts +16 -3
  8. package/dist/cli/commands/audit-commands.js +84 -31
  9. package/dist/cli/commands/codegraph-commands.js +191 -6
  10. package/dist/cli/commands/final-review-commands.d.ts +34 -10
  11. package/dist/cli/commands/final-review-commands.js +130 -34
  12. package/dist/cli/commands/job-commands.js +4 -2
  13. package/dist/cli/commands/scan-commands.js +1 -1
  14. package/dist/cli/commands/share-commands.d.ts +49 -0
  15. package/dist/cli/commands/share-commands.js +114 -14
  16. package/dist/cli/commands/test-commands.d.ts +60 -3
  17. package/dist/cli/commands/test-commands.js +125 -7
  18. package/dist/services/audit/audit-goal-service.js +38 -3
  19. package/dist/services/codegraph/codegraph-autorefresh.js +12 -0
  20. package/dist/services/codegraph/codegraph-exclude-integrity.d.ts +61 -0
  21. package/dist/services/codegraph/codegraph-exclude-integrity.js +98 -0
  22. package/dist/services/codegraph/codegraph-exclude-reconciler.d.ts +26 -0
  23. package/dist/services/codegraph/codegraph-exclude-reconciler.js +217 -0
  24. package/dist/services/codegraph/codegraph-exclude-repair.d.ts +102 -0
  25. package/dist/services/codegraph/codegraph-exclude-repair.js +266 -0
  26. package/dist/services/codegraph/codegraph-preflight-service.js +12 -0
  27. package/dist/services/codegraph/codegraph-service.d.ts +0 -1
  28. package/dist/services/codegraph/codegraph-service.js +5 -4
  29. package/dist/services/doctor/doctor-service/checks/codegraph-exclude-integrity.d.ts +29 -0
  30. package/dist/services/doctor/doctor-service/checks/codegraph-exclude-integrity.js +88 -0
  31. package/dist/services/doctor/doctor-service/checks/ecc-hooks-schema-drift.d.ts +65 -0
  32. package/dist/services/doctor/doctor-service/checks/ecc-hooks-schema-drift.js +186 -0
  33. package/dist/services/doctor/doctor-service/plugin-registry.js +4 -0
  34. package/dist/services/doctor/doctor-service/types.d.ts +47 -0
  35. package/dist/services/final-review/final-review-service.d.ts +154 -0
  36. package/dist/services/final-review/final-review-service.js +621 -7
  37. package/dist/services/final-review/index.d.ts +1 -1
  38. package/dist/services/final-review/index.js +1 -1
  39. package/dist/services/llm/anthropic-runner.d.ts +87 -0
  40. package/dist/services/llm/anthropic-runner.js +171 -0
  41. package/dist/services/llm/stub-runner.d.ts +11 -0
  42. package/dist/services/llm/stub-runner.js +33 -0
  43. package/dist/services/prd/handoff-auto-regen.js +0 -1
  44. package/dist/services/prd/handoff-service.d.ts +9 -1
  45. package/dist/services/prd/handoff-service.js +48 -6
  46. package/dist/services/prd/project-scan-bootstrap-service.js +7 -7
  47. package/dist/services/scan/api-diff-openapi.d.ts +32 -0
  48. package/dist/services/scan/api-diff-openapi.js +359 -0
  49. package/dist/services/scan/api-diff-recorded.d.ts +96 -0
  50. package/dist/services/scan/api-diff-recorded.js +577 -0
  51. package/dist/services/scan/api-diff-service.d.ts +34 -0
  52. package/dist/services/scan/api-diff-service.js +407 -0
  53. package/dist/services/scan/api-diff-types.d.ts +116 -0
  54. package/dist/services/scan/api-diff-types.js +46 -0
  55. package/dist/services/scan/archetype-service.js +27 -1
  56. package/dist/services/scan/existing-system-service.js +17 -4
  57. package/dist/services/scan/hook-convention-service.d.ts +26 -0
  58. package/dist/services/scan/hook-convention-service.js +562 -0
  59. package/dist/services/scan/scan-types.d.ts +47 -0
  60. package/dist/services/session/caller-binding-service.d.ts +28 -0
  61. package/dist/services/session/caller-binding-service.js +10 -2
  62. package/dist/services/session/caller-id-types.d.ts +12 -2
  63. package/dist/services/session/index.d.ts +2 -2
  64. package/dist/services/session/index.js +2 -2
  65. package/dist/services/session/session-binding-bridge.js +11 -6
  66. package/dist/services/session/session-manager.d.ts +33 -1
  67. package/dist/services/session/session-manager.js +84 -25
  68. package/dist/services/skills/skill-presence-service.d.ts +17 -3
  69. package/dist/services/skills/skill-presence-service.js +23 -3
  70. package/package.json +7 -5
  71. package/skills/bee/peaks-rd/SKILL.md +11 -3
  72. package/skills/peaks-code/references/existing-system-extraction.md +5 -1
  73. package/skills/peaks-code/references/frontend-only-mode.md +48 -6
  74. package/skills/peaks-code/references/project-scan-checklist.md +20 -1
  75. package/skills/peaks-doctor/references/doctor-check-catalog.md +1 -0
  76. package/skills/peaks-final-review/SKILL.md +43 -32
@@ -6,14 +6,18 @@
6
6
  *
7
7
  * 1. Auto-detects the framework from package.json (devDependencies +
8
8
  * dependencies) via detectTestFramework().
9
- * 2. Spawns the framework's CLI with --cache enabled (overriding any
9
+ * 2. Resolves the project-LOCAL runner binary (node_modules) and spawns
10
+ * that, so the command works where the runner is not on PATH — notably
11
+ * Windows, where node_modules/.bin/vitest.cmd is not spawnable without
12
+ * a shell. PATH is a last resort and is reported, not silent.
13
+ * 3. Spawns the framework's CLI with --cache enabled (overriding any
10
14
  * --no-cache in the consumer's `test` script). The user can
11
15
  * opt back into no-cache via `peaks test --no-cache` or
12
16
  * `peaks test --passthrough`.
13
- * 3. Skips tests where (fileMtime, fileSha256) is unchanged AND the
17
+ * 4. Skips tests where (fileMtime, fileSha256) is unchanged AND the
14
18
  * previous run status was 'passed' (per-test fingerprint cache at
15
19
  * `<projectRoot>/.peaks/_runtime/test-cache/<hash>.json`).
16
- * 4. Exits 0 on all-pass / all-skip; exits 1 on any failure.
20
+ * 5. Exits 0 on all-pass / all-skip; exits 1 on any failure.
17
21
  *
18
22
  * The CLI is invoked by USER (not just by skill) per slice 2.5.0
19
23
  * sub-fix B (G16) — a documented exception to the
@@ -30,6 +34,8 @@
30
34
  * peaks test --framework <name> — force a specific framework
31
35
  */
32
36
  import { spawn } from 'node:child_process';
37
+ import { existsSync, readFileSync } from 'node:fs';
38
+ import { dirname, join, resolve } from 'node:path';
33
39
  import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
34
40
  import { getErrorMessage } from '../cli-helpers.js';
35
41
  import { clearTestCache, detectTestFramework } from '../../services/test-cache/test-cache-service.js';
@@ -81,10 +87,120 @@ export function buildRunnerArgv(framework, patterns, options) {
81
87
  // mocha — no built-in --cache flag; we just pass the patterns.
82
88
  return [...patterns];
83
89
  }
84
- function runRunner(framework, argv, projectRoot) {
90
+ /** `<pkg>/package.json#bin`, resolved to the absolute JS entry it points at. */
91
+ function readBinEntry(pkgJsonPath, name, read) {
92
+ let pkg = {};
93
+ try {
94
+ pkg = JSON.parse(read(pkgJsonPath));
95
+ }
96
+ catch {
97
+ // Not fatal: fall through to the `.bin` shim below, and the not-found
98
+ // error still names this package.json in `searched`.
99
+ pkg = {};
100
+ }
101
+ const rel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.[name];
102
+ if (typeof rel !== 'string' || rel.length === 0)
103
+ return null;
104
+ return resolve(dirname(pkgJsonPath), rel);
105
+ }
106
+ /**
107
+ * Convert a resolved executable + argv into a form `spawn` can launch with
108
+ * `shell: false`.
109
+ *
110
+ * A Windows `.cmd`/`.bat` shim cannot be spawned directly (spawn → EINVAL).
111
+ * The obvious fix, `spawn(shim, argv, { shell: true })`, re-splits argv inside
112
+ * cmd.exe: a pattern `tests/a b/x.test.ts` arrives as THREE args (measured).
113
+ * Invoking cmd.exe ourselves with `/d /s /c` keeps argv intact.
114
+ */
115
+ function toSpawnable(exe, argv, platform, env) {
116
+ if (platform === 'win32' && /\.(?:cmd|bat)$/i.test(exe)) {
117
+ return { command: env.ComSpec ?? 'cmd.exe', args: ['/d', '/s', '/c', exe, ...argv] };
118
+ }
119
+ return { command: exe, args: argv };
120
+ }
121
+ /** Spawnable PATH hits, in preference order (`where`/`which` avoided so this
122
+ * stays in-process and testable). */
123
+ function resolveFromPath(name, platform, env, exists) {
124
+ const dirs = (env.PATH ?? '').split(platform === 'win32' ? ';' : ':').filter((d) => d.length > 0);
125
+ const exts = platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
126
+ for (const dir of dirs) {
127
+ for (const ext of exts) {
128
+ const candidate = join(dir, name + ext);
129
+ if (exists(candidate))
130
+ return candidate;
131
+ }
132
+ }
133
+ return null;
134
+ }
135
+ /**
136
+ * Resolve the consumer project's LOCAL runner, and the form of it that
137
+ * `spawn` can actually launch on this platform.
138
+ *
139
+ * Probed, in order (every probe is reported when nothing is found):
140
+ * 1. `<root>/node_modules/.bin/<runner>` (+ `.cmd`/`.exe` on Windows) —
141
+ * the project-local runner the command documents.
142
+ * 2. `<root>/node_modules/<runner>/package.json` → its `bin` JS entry.
143
+ * 3. PATH — last resort only; the caller prints a visible notice.
144
+ *
145
+ * Between 1 and 2 the **JS entry** wins: `spawn` runs it as
146
+ * `node <entry> …`, which is identical on Windows and POSIX and never
147
+ * routes argv through a shell. The `.cmd` shim is only a fallback because
148
+ * it needs cmd.exe to launch it (see `toSpawnable`).
149
+ */
150
+ export function resolveRunner(framework, argv, projectRoot, deps = {}) {
151
+ const platform = deps.platform ?? process.platform;
152
+ const exists = deps.existsSync ?? existsSync;
153
+ const read = deps.readFileSync ?? ((path) => readFileSync(path, 'utf8'));
154
+ const env = deps.env ?? process.env;
155
+ const nodeExec = deps.nodeExecPath ?? process.execPath;
156
+ const searched = [];
157
+ // 1. Project-local `.bin` shim.
158
+ const binDir = join(projectRoot, 'node_modules', '.bin');
159
+ const shimExts = platform === 'win32' ? ['.cmd', '.exe'] : [''];
160
+ let shim = null;
161
+ for (const ext of shimExts) {
162
+ const candidate = join(binDir, framework + ext);
163
+ searched.push(candidate);
164
+ if (shim === null && exists(candidate))
165
+ shim = candidate;
166
+ }
167
+ // 2. Project-local package entry.
168
+ const pkgJsonPath = join(projectRoot, 'node_modules', framework, 'package.json');
169
+ searched.push(pkgJsonPath);
170
+ const entry = exists(pkgJsonPath) ? readBinEntry(pkgJsonPath, framework, read) : null;
171
+ if (entry !== null && exists(entry)) {
172
+ return { ok: true, command: nodeExec, args: [entry, ...argv], via: `local ${entry}`, fromPath: false };
173
+ }
174
+ if (shim !== null) {
175
+ return { ok: true, ...toSpawnable(shim, argv, platform, env), via: `local ${shim}`, fromPath: false };
176
+ }
177
+ // 3. PATH — last resort.
178
+ const onPath = resolveFromPath(framework, platform, env, exists);
179
+ searched.push(`PATH lookup for "${framework}"`);
180
+ if (onPath !== null) {
181
+ return { ok: true, ...toSpawnable(onPath, argv, platform, env), via: `PATH: ${onPath}`, fromPath: true };
182
+ }
183
+ return { ok: false, searched };
184
+ }
185
+ /** Actionable message for the no-runner case — never a raw ENOENT. */
186
+ export function formatRunnerNotFound(framework, searched) {
187
+ return [
188
+ `RUNNER_NOT_FOUND: no ${framework} runner found for this project. Looked for:`,
189
+ ...searched.map((p) => ` - ${p}`),
190
+ `Install it (e.g. \`npm i -D ${framework}\`) or pass --framework <name>.`
191
+ ].join('\n');
192
+ }
193
+ export function runRunner(framework, argv, projectRoot, deps = {}) {
194
+ const resolution = resolveRunner(framework, argv, projectRoot, deps);
195
+ if (!resolution.ok) {
196
+ return Promise.reject(new Error(formatRunnerNotFound(framework, resolution.searched)));
197
+ }
198
+ const notice = resolution.fromPath
199
+ ? `[peaks test] no local ${framework} under ${join(projectRoot, 'node_modules')}; falling back to ${resolution.via}\n`
200
+ : null;
85
201
  return new Promise((resolveRun, reject) => {
86
- const cmd = framework === 'vitest' ? 'vitest' : framework === 'jest' ? 'jest' : 'mocha';
87
- const proc = spawn(cmd, argv, {
202
+ const spawnFn = deps.spawnFn ?? spawn;
203
+ const proc = spawnFn(resolution.command, resolution.args, {
88
204
  cwd: projectRoot,
89
205
  env: process.env,
90
206
  stdio: ['ignore', 'pipe', 'pipe']
@@ -95,7 +211,7 @@ function runRunner(framework, argv, projectRoot) {
95
211
  proc.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); });
96
212
  proc.on('error', (err) => reject(err));
97
213
  proc.on('close', (code) => {
98
- resolveRun({ code: code ?? 0, stdout, stderr });
214
+ resolveRun({ code: code ?? 0, stdout, stderr, notice });
99
215
  });
100
216
  });
101
217
  }
@@ -173,6 +289,8 @@ export function registerTestCommands(program, _io) {
173
289
  });
174
290
  // Stream the runner's output to the user.
175
291
  const result = await runRunner(framework, argv, projectRoot);
292
+ if (result.notice !== null)
293
+ process.stderr.write(result.notice);
176
294
  process.stdout.write(result.stdout);
177
295
  process.stderr.write(result.stderr);
178
296
  if (result.code !== 0) {
@@ -31,19 +31,39 @@ const REQUIRED_DIMENSIONS = [
31
31
  'alternatives',
32
32
  'constraints'
33
33
  ];
34
+ /**
35
+ * The allowed values for the three constrained fields.
36
+ *
37
+ * These are stated in the prompt AND enforced below: a prompt alone is a
38
+ * request, not a gate. Before this, the prompt named only `severity` with no
39
+ * allowed values, the model answered `severity: "high"`, and the gate passed
40
+ * it — leaving every downstream consumer that switches on
41
+ * `info | concern | blocker` holding a value it has never seen.
42
+ */
43
+ const SEVERITIES = ['info', 'concern', 'blocker'];
44
+ const EFFORTS = ['small', 'medium', 'large', 'epic'];
45
+ const CONFIDENCES = ['high', 'medium', 'low'];
34
46
  const SYSTEM_PROMPT = `You are auditing a software development need. Produce a structured JSON response with EXACTLY these fields:
35
47
  - summary (1-2 sentence summary of the need)
36
48
  - audit (array of EXACTLY 6 objects, one per dimension: correctness, completeness, scope, risks, alternatives, constraints; each with dimension, finding, severity)
37
49
  - proposedGoal (what success looks like)
38
50
  - successCriteria (list of acceptance criteria)
39
- - roughEffort (small | medium | large | epic)
40
- - confidence (high | medium | low)
51
+ - roughEffort (one of exactly: small | medium | large | epic)
52
+ - confidence (one of exactly: high | medium | low)
41
53
  - rationale (one paragraph tying audit to goal)
42
54
 
55
+ severity is one of exactly: info | concern | blocker. Do not invent other values.
56
+ Any value outside the lists above is rejected and the whole response is discarded — a value that is merely plausible is not acceptable.
57
+
43
58
  Output ONLY valid JSON, no prose.`;
44
59
  export async function auditGoal(input, llmRunner) {
45
60
  const userPrompt = `Need: ${input.need}\n\nAudit this need across the 6 dimensions and propose a goal.`;
46
- const response = await llmRunner.call(SYSTEM_PROMPT, userPrompt, { maxTokens: 2000 });
61
+ // 8000, not 2000: the reply is a six-dimension JSON audit, and a reasoning
62
+ // model shares this budget with its own thinking blocks — measured against
63
+ // the session's provider, a 2000-token cap returns `stop_reason: max_tokens`
64
+ // with the JSON cut off mid-string. That is still ONE bounded call; it is
65
+ // just one the model can finish.
66
+ const response = await llmRunner.call(SYSTEM_PROMPT, userPrompt, { maxTokens: 8000 });
47
67
  let parsed;
48
68
  try {
49
69
  parsed = JSON.parse(response.output);
@@ -59,6 +79,21 @@ export async function auditGoal(input, llmRunner) {
59
79
  if (missing.length > 0) {
60
80
  throw new IncompleteAuditError(`Missing required audit dimensions: ${missing.join(', ')}`);
61
81
  }
82
+ // Coverage is not validity: a reply can carry all six dimensions and still
83
+ // hand downstream consumers enum values that do not exist. An out-of-enum
84
+ // value fails here — it is never coerced into a valid one, because silently
85
+ // repairing the model's output is what makes the enum decorative.
86
+ for (const entry of parsed.audit) {
87
+ if (!SEVERITIES.includes(entry.severity)) {
88
+ throw new IncompleteAuditError(`Invalid severity for dimension "${entry.dimension}": ${JSON.stringify(entry.severity)}. Allowed values: ${SEVERITIES.join(', ')}.`);
89
+ }
90
+ }
91
+ if (!EFFORTS.includes(parsed.roughEffort)) {
92
+ throw new IncompleteAuditError(`Invalid roughEffort: ${JSON.stringify(parsed.roughEffort)}. Allowed values: ${EFFORTS.join(', ')}.`);
93
+ }
94
+ if (!CONFIDENCES.includes(parsed.confidence)) {
95
+ throw new IncompleteAuditError(`Invalid confidence: ${JSON.stringify(parsed.confidence)}. Allowed values: ${CONFIDENCES.join(', ')}.`);
96
+ }
62
97
  return parsed;
63
98
  }
64
99
  function isAuditGoalOutput(value) {
@@ -38,6 +38,7 @@
38
38
  import { existsSync, statSync } from 'node:fs';
39
39
  import { join } from 'node:path';
40
40
  import { CODEGRAPH_DIR_NAME, CODEGRAPH_MARKER_NAME, createCodegraphInvocation, executeCodegraphInvocation, isCodegraphInitialized, } from './codegraph-service.js';
41
+ import { repairCodegraphExcludeFromProject } from './codegraph-exclude-repair.js';
41
42
  /**
42
43
  * True when `<projectRoot>/.codegraph/` exists and is a directory.
43
44
  * Pure fs probe; never throws.
@@ -107,6 +108,17 @@ export async function refreshCodegraphAfterSlice(projectRoot, runner) {
107
108
  note: `auto codegraph refresh self-heal init failed (exit ${String(initResult.exitCode)}): ${firstMeaningfulLine(initResult.stderr || initResult.stdout)}`,
108
109
  };
109
110
  }
111
+ // That init just wrote upstream's 99-rule default `exclude`
112
+ // template, some of which block tracked source files — the same
113
+ // self-heal the CLI's `peaks codegraph init` performs, via the
114
+ // same shared helper. Skipped, this path would stamp the
115
+ // peaks-loop marker over an incomplete index that no later
116
+ // `init` (it would no-op) could ever repair.
117
+ //
118
+ // Never throws (the helper catches everything), and
119
+ // `reindex: false` because the index call below covers the
120
+ // recovered files.
121
+ await repairCodegraphExcludeFromProject(projectRoot, runner, { reindex: false });
110
122
  }
111
123
  const invocation = createCodegraphInvocation({ subcommand: 'index', project: projectRoot, quiet: true });
112
124
  const result = await executeCodegraphInvocation(invocation, runner);
@@ -0,0 +1,61 @@
1
+ import { type CodegraphExcludeViolation } from './codegraph-exclude-reconciler.js';
2
+ /**
3
+ * Exit code `peaks codegraph status` uses when the index is
4
+ * demonstrably incomplete. Distinct from the upstream pass-through
5
+ * exit code and from `CODEGRAPH_INIT_CONFLICT_EXIT_CODE` (73) so a CI
6
+ * job can tell "codegraph said no" apart from "peaks-loop found a
7
+ * tracked source file the index silently dropped".
8
+ */
9
+ export declare const CODEGRAPH_INTEGRITY_EXIT_CODE = 74;
10
+ export type CodegraphExcludeRuleImpact = {
11
+ /** The `exclude` rule that must be dropped. */
12
+ readonly rule: string;
13
+ /** Distinct tracked source files this single rule blocks. */
14
+ readonly blockedCount: number;
15
+ };
16
+ export type CodegraphExcludeIntegrityReport = {
17
+ /** Absolute path of the reconciled `.codegraph/config.json`. */
18
+ readonly configPath: string;
19
+ /** True when at least one tracked source file is blocked. */
20
+ readonly gap: boolean;
21
+ /** Tracked files that pass the config's `include` filter at all. */
22
+ readonly trackedSourceCount: number;
23
+ /** Distinct tracked source files blocked by at least one rule. */
24
+ readonly excludedTrackedCount: number;
25
+ /** One entry per (file, rule) pair — see S1's reconciler. */
26
+ readonly violations: readonly CodegraphExcludeViolation[];
27
+ /** Rules that must be dropped; empty exactly when `gap` is false. */
28
+ readonly rulesToRemove: readonly string[];
29
+ /** Per-rule blocked-file counts, in `rulesToRemove` order. */
30
+ readonly ruleImpacts: readonly CodegraphExcludeRuleImpact[];
31
+ };
32
+ /**
33
+ * True when `<projectRoot>/.codegraph/config.json` exists, i.e. when an
34
+ * exclude list is actually in play here. Both consumers use this to
35
+ * stay SILENT on a project that never ran `peaks codegraph init`: there
36
+ * is no exclusion to report, and a missing config is not a finding.
37
+ *
38
+ * Note the difference from `isCodegraphInitialized` in
39
+ * `codegraph-service.ts`, which probes `codegraph.db` (upstream's own
40
+ * definition of "initialized"). The exclude list is written by upstream
41
+ * init, so its presence is the narrower question this module asks.
42
+ */
43
+ export declare function isCodegraphExcludeConfigPresent(projectRoot: string): boolean;
44
+ /**
45
+ * Read the project's git-tracked files + `.codegraph/config.json` and
46
+ * fold the reconciliation into a report.
47
+ *
48
+ * Throws (never silently degrades) when the project is not a git work
49
+ * tree, when the config is missing, or when it is malformed — callers
50
+ * that must stay alive (doctor, `status`) catch and surface the reason.
51
+ */
52
+ export declare function inspectCodegraphExcludeIntegrity(projectRoot: string): CodegraphExcludeIntegrityReport;
53
+ /**
54
+ * Human-readable detail lines for a gapped report: the headline count,
55
+ * then the offending rules, then a sample of the blocked files so an
56
+ * operator can point at a concrete path without re-running anything.
57
+ *
58
+ * Returns an empty array for a clean report — the caller decides
59
+ * whether "clean" is worth printing at all.
60
+ */
61
+ export declare function renderCodegraphExcludeIntegrityLines(report: CodegraphExcludeIntegrityReport): readonly string[];
@@ -0,0 +1,98 @@
1
+ // src/services/codegraph/codegraph-exclude-integrity.ts
2
+ //
3
+ // Slice S2 of `2026-09-12-codegraph-exclude-integrity` — the READ-ONLY
4
+ // integrity report shared by `peaks codegraph status` and the
5
+ // `capability:codegraph-exclude-integrity` doctor check.
6
+ //
7
+ // S1's reconciler computes the raw truth (`violations` /
8
+ // `rulesToRemove`). This module folds it into a verdict-shaped report
9
+ // that both consumers can render and gate on, so neither of them has
10
+ // to re-derive "is there a gap, how big, and which rules cause it".
11
+ //
12
+ // It NEVER writes `.codegraph/config.json`. The only write path is
13
+ // `codegraph-exclude-repair.ts`, reachable from `peaks codegraph init`
14
+ // (fresh-initialization self-heal) and the explicit
15
+ // `peaks codegraph repair-exclude` command. Read stays read.
16
+ import { existsSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { CODEGRAPH_CONFIG_FILENAME, reconcileCodegraphExcludeFromProject } from './codegraph-exclude-reconciler.js';
19
+ import { CODEGRAPH_DIR_NAME } from './codegraph-service.js';
20
+ /**
21
+ * Exit code `peaks codegraph status` uses when the index is
22
+ * demonstrably incomplete. Distinct from the upstream pass-through
23
+ * exit code and from `CODEGRAPH_INIT_CONFLICT_EXIT_CODE` (73) so a CI
24
+ * job can tell "codegraph said no" apart from "peaks-loop found a
25
+ * tracked source file the index silently dropped".
26
+ */
27
+ export const CODEGRAPH_INTEGRITY_EXIT_CODE = 74;
28
+ /** How many offending rules and blocked files we name on the human path. */
29
+ const MAX_REPORTED_RULES = 10;
30
+ const MAX_REPORTED_FILES = 10;
31
+ /**
32
+ * True when `<projectRoot>/.codegraph/config.json` exists, i.e. when an
33
+ * exclude list is actually in play here. Both consumers use this to
34
+ * stay SILENT on a project that never ran `peaks codegraph init`: there
35
+ * is no exclusion to report, and a missing config is not a finding.
36
+ *
37
+ * Note the difference from `isCodegraphInitialized` in
38
+ * `codegraph-service.ts`, which probes `codegraph.db` (upstream's own
39
+ * definition of "initialized"). The exclude list is written by upstream
40
+ * init, so its presence is the narrower question this module asks.
41
+ */
42
+ export function isCodegraphExcludeConfigPresent(projectRoot) {
43
+ return existsSync(join(projectRoot, CODEGRAPH_DIR_NAME, CODEGRAPH_CONFIG_FILENAME));
44
+ }
45
+ /**
46
+ * Read the project's git-tracked files + `.codegraph/config.json` and
47
+ * fold the reconciliation into a report.
48
+ *
49
+ * Throws (never silently degrades) when the project is not a git work
50
+ * tree, when the config is missing, or when it is malformed — callers
51
+ * that must stay alive (doctor, `status`) catch and surface the reason.
52
+ */
53
+ export function inspectCodegraphExcludeIntegrity(projectRoot) {
54
+ const result = reconcileCodegraphExcludeFromProject(projectRoot);
55
+ const blockedCounts = new Map();
56
+ for (const violation of result.violations) {
57
+ blockedCounts.set(violation.matchedRule, (blockedCounts.get(violation.matchedRule) ?? 0) + 1);
58
+ }
59
+ return {
60
+ configPath: join(projectRoot, CODEGRAPH_DIR_NAME, CODEGRAPH_CONFIG_FILENAME),
61
+ gap: result.excludedTrackedCount > 0,
62
+ trackedSourceCount: result.trackedSourceCount,
63
+ excludedTrackedCount: result.excludedTrackedCount,
64
+ violations: result.violations,
65
+ rulesToRemove: result.rulesToRemove,
66
+ ruleImpacts: result.rulesToRemove.map((rule) => ({ rule, blockedCount: blockedCounts.get(rule) ?? 0 }))
67
+ };
68
+ }
69
+ /**
70
+ * Human-readable detail lines for a gapped report: the headline count,
71
+ * then the offending rules, then a sample of the blocked files so an
72
+ * operator can point at a concrete path without re-running anything.
73
+ *
74
+ * Returns an empty array for a clean report — the caller decides
75
+ * whether "clean" is worth printing at all.
76
+ */
77
+ export function renderCodegraphExcludeIntegrityLines(report) {
78
+ if (!report.gap) {
79
+ return [];
80
+ }
81
+ const lines = [
82
+ `[FAIL] codegraph index is incomplete: ${report.excludedTrackedCount} of ${report.trackedSourceCount} tracked source files are excluded by ${report.rulesToRemove.length} rule(s).`
83
+ ];
84
+ for (const impact of report.ruleImpacts.slice(0, MAX_REPORTED_RULES)) {
85
+ lines.push(` rule ${impact.rule} blocks ${impact.blockedCount} tracked file(s)`);
86
+ }
87
+ if (report.ruleImpacts.length > MAX_REPORTED_RULES) {
88
+ lines.push(` … and ${report.ruleImpacts.length - MAX_REPORTED_RULES} more rule(s)`);
89
+ }
90
+ for (const violation of report.violations.slice(0, MAX_REPORTED_FILES)) {
91
+ lines.push(` excluded: ${violation.path} <- ${violation.matchedRule}`);
92
+ }
93
+ if (report.violations.length > MAX_REPORTED_FILES) {
94
+ lines.push(` … and ${report.violations.length - MAX_REPORTED_FILES} more file/rule pair(s)`);
95
+ }
96
+ lines.push('Run `peaks codegraph repair-exclude --project <root>` to drop these rules, back up the config, and rebuild the index.');
97
+ return lines;
98
+ }
@@ -0,0 +1,26 @@
1
+ export declare function matchesCodegraphGlob(filePath: string, pattern: string): boolean;
2
+ export type CodegraphExcludeReconcileInput = {
3
+ readonly trackedFiles: readonly string[];
4
+ readonly include: readonly string[];
5
+ readonly exclude: readonly string[];
6
+ };
7
+ export type CodegraphExcludeViolation = {
8
+ readonly path: string;
9
+ readonly matchedRule: string;
10
+ };
11
+ export type CodegraphExcludeReconcileResult = {
12
+ readonly violations: readonly CodegraphExcludeViolation[];
13
+ readonly rulesToRemove: readonly string[];
14
+ readonly trackedSourceCount: number;
15
+ readonly excludedTrackedCount: number;
16
+ };
17
+ export declare function reconcileCodegraphExclude(input: CodegraphExcludeReconcileInput): CodegraphExcludeReconcileResult;
18
+ export declare const CODEGRAPH_CONFIG_FILENAME = "config.json";
19
+ export type CodegraphExcludeConfig = {
20
+ readonly include: readonly string[];
21
+ readonly exclude: readonly string[];
22
+ };
23
+ export declare function readTrackedFiles(projectRoot: string): readonly string[];
24
+ export declare function assertStringArray(value: unknown, field: string, configPath: string): readonly string[];
25
+ export declare function readCodegraphExcludeConfig(projectRoot: string): CodegraphExcludeConfig;
26
+ export declare function reconcileCodegraphExcludeFromProject(projectRoot: string): CodegraphExcludeReconcileResult;