peaks-loop 4.0.43 → 4.0.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/dist/cli/commands/codegraph-commands.d.ts +1 -0
- package/dist/cli/commands/codegraph-commands.js +239 -8
- package/dist/cli/commands/final-review-commands.d.ts +34 -10
- package/dist/cli/commands/final-review-commands.js +132 -34
- package/dist/cli/commands/share-commands.d.ts +49 -0
- package/dist/cli/commands/share-commands.js +114 -14
- package/dist/services/codegraph/codegraph-autorefresh.js +12 -0
- package/dist/services/codegraph/codegraph-exclude-integrity.d.ts +61 -0
- package/dist/services/codegraph/codegraph-exclude-integrity.js +98 -0
- package/dist/services/codegraph/codegraph-exclude-reconciler.d.ts +26 -0
- package/dist/services/codegraph/codegraph-exclude-reconciler.js +217 -0
- package/dist/services/codegraph/codegraph-exclude-repair.d.ts +102 -0
- package/dist/services/codegraph/codegraph-exclude-repair.js +266 -0
- package/dist/services/codegraph/codegraph-preflight-service.js +12 -0
- package/dist/services/codegraph/codegraph-service.d.ts +0 -1
- package/dist/services/codegraph/codegraph-service.js +5 -4
- package/dist/services/doctor/doctor-service/checks/codegraph-exclude-integrity.d.ts +29 -0
- package/dist/services/doctor/doctor-service/checks/codegraph-exclude-integrity.js +88 -0
- package/dist/services/doctor/doctor-service/plugin-registry.js +2 -0
- package/dist/services/doctor/doctor-service/types.d.ts +27 -0
- package/dist/services/final-review/final-review-service.d.ts +335 -1
- package/dist/services/final-review/final-review-service.js +1457 -6
- package/dist/services/final-review/index.d.ts +2 -1
- package/dist/services/final-review/index.js +2 -1
- package/dist/services/final-review/pre-post-diff.d.ts +137 -0
- package/dist/services/final-review/pre-post-diff.js +657 -0
- package/dist/services/prd/handoff-auto-regen.js +0 -1
- package/dist/services/prd/handoff-service.d.ts +9 -1
- package/dist/services/prd/handoff-service.js +48 -6
- package/package.json +7 -5
- package/skills/peaks-final-review/SKILL.md +79 -35
- package/skills/peaks-final-review/references/4-dimensions.md +42 -5
|
@@ -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;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// src/services/codegraph/codegraph-exclude-reconciler.ts
|
|
2
|
+
//
|
|
3
|
+
// Slice S1 of `2026-09-12-codegraph-exclude-integrity` — the pure
|
|
4
|
+
// reconciliation core. It answers one question:
|
|
5
|
+
//
|
|
6
|
+
// "Which git-tracked source files does the codegraph `exclude` list
|
|
7
|
+
// silently block, and which rules must be removed to unblock them?"
|
|
8
|
+
//
|
|
9
|
+
// Why this exists (the real defect): upstream `@colbymchenry/codegraph`
|
|
10
|
+
// ships a 99-entry default `exclude` template matched by *directory
|
|
11
|
+
// name* via `picomatch.isMatch(path, pattern, { dot: true })`. Upstream
|
|
12
|
+
// `mergeConfig` has no separate override channel — `config.json`'s
|
|
13
|
+
// `exclude` array replaces the defaults wholesale — so any default rule
|
|
14
|
+
// whose directory name collides with real source silently drops tracked
|
|
15
|
+
// files from the index while `peaks codegraph status` still reports
|
|
16
|
+
// `[OK] Index is up to date`. This repo has five such rules:
|
|
17
|
+
// `**` + `/artifacts/**`, `**` + `/release/**`, `**` + `/vendor/**`,
|
|
18
|
+
// `**` + `/bin/**`, `**` + `/publish/**`.
|
|
19
|
+
//
|
|
20
|
+
// Scope of this module:
|
|
21
|
+
// - PURE: `reconcileCodegraphExclude` takes already-resolved data and
|
|
22
|
+
// computes violations + the rules to remove. No fs, no spawn.
|
|
23
|
+
// - ADAPTER: `readTrackedFiles` / `readCodegraphExcludeConfig` /
|
|
24
|
+
// `reconcileCodegraphExcludeFromProject` are the thin
|
|
25
|
+
// boundary-reading helpers. They READ ONLY — this module never
|
|
26
|
+
// writes `.codegraph/config.json` (that is S2's repair path).
|
|
27
|
+
//
|
|
28
|
+
// It is deliberately generic: no hardcoded rule list. Any rule that
|
|
29
|
+
// blocks a tracked source file is caught by the same logic, in this
|
|
30
|
+
// repo or any other.
|
|
31
|
+
//
|
|
32
|
+
// NOTE FOR FUTURE EDITORS: glob literals contain the two-character
|
|
33
|
+
// sequence that ends a block comment, so every comment in this file
|
|
34
|
+
// uses `//` lines. Do not convert them to `/* ... */`.
|
|
35
|
+
import { execFileSync } from 'node:child_process';
|
|
36
|
+
import { readFileSync } from 'node:fs';
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
import picomatch from 'picomatch';
|
|
39
|
+
import { normalizePath } from '../../shared/path-utils.js';
|
|
40
|
+
import { CODEGRAPH_DIR_NAME } from './codegraph-service.js';
|
|
41
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
42
|
+
// Glob matching — delegated to `picomatch`, the same engine upstream uses
|
|
43
|
+
//
|
|
44
|
+
// Upstream `@colbymchenry/codegraph` matches its `exclude` / `include`
|
|
45
|
+
// globs with `picomatch.isMatch(path, pattern, { dot: true })`.
|
|
46
|
+
//
|
|
47
|
+
// This module used to re-implement that matcher by hand, covering only
|
|
48
|
+
// `**`, `*` and `?`. That was a silent-failure generator: the hand-rolled
|
|
49
|
+
// version returned `false` for every *extended* glob (`{a,b}`, `[abc]`,
|
|
50
|
+
// `!(...)`), so a config carrying `**/{release,artifacts}/**` was
|
|
51
|
+
// reconciled as clean while the gap was still there — `init` and
|
|
52
|
+
// `repair-exclude` reported success over an incomplete index, which is
|
|
53
|
+
// the exact failure this mechanism exists to prevent. The generated
|
|
54
|
+
// regexes were also superlinear in the number of `**/` segments.
|
|
55
|
+
//
|
|
56
|
+
// So the matcher IS picomatch now: promoted from a transitive dependency
|
|
57
|
+
// of `@colbymchenry/codegraph` to a direct one, pinned to the version
|
|
58
|
+
// already in the tree (4.0.4). No new install weight, no fork of the
|
|
59
|
+
// semantics, and no way to drift from upstream again.
|
|
60
|
+
//
|
|
61
|
+
// `dot: true` keeps upstream's semantics: `*` and `?` also match a
|
|
62
|
+
// leading `.`.
|
|
63
|
+
//
|
|
64
|
+
// NOTE: this delegation was differential-tested against the previous
|
|
65
|
+
// implementation over every git-tracked file in this repo x every rule
|
|
66
|
+
// of both the upstream 99-rule default template and this repo's config,
|
|
67
|
+
// with zero disagreements on the vocabulary the old matcher supported —
|
|
68
|
+
// the only differences being the extended globs it used to get wrong.
|
|
69
|
+
//
|
|
70
|
+
// One side effect of the delegation had to be repaired right after it
|
|
71
|
+
// shipped: `picomatch('')` throws, so a config carrying `"exclude": [""]`
|
|
72
|
+
// made the whole reconciliation throw, and `peaks codegraph status`
|
|
73
|
+
// turned that into `[WARN] … not evaluated` with exit 0 — a silent false
|
|
74
|
+
// pass over a real index gap, which is precisely what this module exists
|
|
75
|
+
// to prevent. Empty / whitespace-only rules are now skipped before
|
|
76
|
+
// compilation (`isUnmatchableRule`), so no single junk entry can mask the
|
|
77
|
+
// verdict of the other rules.
|
|
78
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
79
|
+
const PICOMATCH_OPTIONS = { dot: true };
|
|
80
|
+
// `picomatch(glob, options)` parses the glob once and returns a reusable
|
|
81
|
+
// matcher. Compiling per rule (not per file x rule) is what keeps a
|
|
82
|
+
// reconciliation over N files x M rules linear in N.
|
|
83
|
+
function compileGlob(pattern) {
|
|
84
|
+
return { pattern, match: picomatch(pattern, PICOMATCH_OPTIONS) };
|
|
85
|
+
}
|
|
86
|
+
// A rule that carries no glob at all — empty or whitespace-only — cannot
|
|
87
|
+
// name a project-relative source path, so it is skipped instead of
|
|
88
|
+
// compiled.
|
|
89
|
+
//
|
|
90
|
+
// Why this guard exists (regression introduced by the very commit that
|
|
91
|
+
// delegated matching to picomatch): `picomatch('')` THROWS
|
|
92
|
+
// ("Expected pattern to be a non-empty string"). A config carrying
|
|
93
|
+
// `"exclude": [""]` therefore aborted the whole reconciliation, and the
|
|
94
|
+
// consumers degraded that throw into a silent false pass — `status`
|
|
95
|
+
// printed `[WARN] codegraph exclude integrity not evaluated` and left
|
|
96
|
+
// the exit code at 0 while tracked source files were still excluded.
|
|
97
|
+
// Skipping the rule is semantically exact (it matches nothing) and keeps
|
|
98
|
+
// every OTHER rule's verdict, so one junk entry can never mask a real
|
|
99
|
+
// gap. `picomatch(' ')` does not throw; it simply matches no real path,
|
|
100
|
+
// so dropping it is a no-op with the same outcome.
|
|
101
|
+
function isUnmatchableRule(pattern) {
|
|
102
|
+
return pattern.trim().length === 0;
|
|
103
|
+
}
|
|
104
|
+
// Compile a rule list, dropping the rules that cannot match anything.
|
|
105
|
+
function compileRules(patterns) {
|
|
106
|
+
return patterns.filter((pattern) => !isUnmatchableRule(pattern)).map(compileGlob);
|
|
107
|
+
}
|
|
108
|
+
// Does `filePath` match `pattern` under the same rules upstream uses?
|
|
109
|
+
// Exported so the test suite can pin the glob semantics directly,
|
|
110
|
+
// including the negative case: the artifacts rule must NOT match
|
|
111
|
+
// `src/artifactsman/foo.ts`.
|
|
112
|
+
//
|
|
113
|
+
// Total by construction: an unmatchable rule answers `false` rather than
|
|
114
|
+
// throwing, so no caller can be turned into a false pass by one junk
|
|
115
|
+
// entry in the config. For every non-empty pattern the answer is the
|
|
116
|
+
// picomatch answer, unchanged.
|
|
117
|
+
export function matchesCodegraphGlob(filePath, pattern) {
|
|
118
|
+
if (isUnmatchableRule(pattern)) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
return compileGlob(pattern).match(normalizePath(filePath));
|
|
122
|
+
}
|
|
123
|
+
// Reconcile the codegraph `exclude` list against the set of git-tracked
|
|
124
|
+
// source files. Pure: no fs, no spawn, no clock.
|
|
125
|
+
//
|
|
126
|
+
// The `include` filter runs first: a rule that only blocks files the
|
|
127
|
+
// index would not ingest anyway (e.g. a markdown-only rule) is not a
|
|
128
|
+
// violation and is not removed.
|
|
129
|
+
export function reconcileCodegraphExclude(input) {
|
|
130
|
+
// Unmatchable rules are dropped from BOTH lists before compiling — see
|
|
131
|
+
// `isUnmatchableRule`. An empty `include` entry admits nothing, which
|
|
132
|
+
// is the same verdict as an explicitly empty `include` list, so it
|
|
133
|
+
// needs no special case here.
|
|
134
|
+
const includeRules = compileRules(input.include);
|
|
135
|
+
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
|
+
}
|
|
143
|
+
const violations = [];
|
|
144
|
+
const offendingRules = new Set();
|
|
145
|
+
const blockedFiles = new Set();
|
|
146
|
+
for (const path of trackedSourceFiles) {
|
|
147
|
+
for (const rule of excludeRules) {
|
|
148
|
+
if (!rule.match(path)) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
violations.push({ path, matchedRule: rule.pattern });
|
|
152
|
+
offendingRules.add(rule.pattern);
|
|
153
|
+
blockedFiles.add(path);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
violations,
|
|
158
|
+
rulesToRemove: input.exclude.filter((pattern) => offendingRules.has(pattern)),
|
|
159
|
+
trackedSourceCount: trackedSourceFiles.length,
|
|
160
|
+
excludedTrackedCount: blockedFiles.size
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
164
|
+
// Thin boundary adapters — READ ONLY
|
|
165
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
166
|
+
// Upstream `CONFIG_FILENAME` inside `<projectRoot>/.codegraph/`.
|
|
167
|
+
export const CODEGRAPH_CONFIG_FILENAME = 'config.json';
|
|
168
|
+
// Project-relative paths of every git-tracked file, exactly as
|
|
169
|
+
// `git ls-files` reports them. Why git and not an fs walk: the index
|
|
170
|
+
// must cover what git tracks (see the anti-fake-green contract in
|
|
171
|
+
// `src/services/dispatch/dispatch-sub-agent.ts`), so git is the only
|
|
172
|
+
// admissible source of truth.
|
|
173
|
+
//
|
|
174
|
+
// Throws when `projectRoot` is not inside a git work tree — callers
|
|
175
|
+
// decide whether that is fatal; this function never swallows it.
|
|
176
|
+
export function readTrackedFiles(projectRoot) {
|
|
177
|
+
const stdout = execFileSync('git', ['-C', projectRoot, 'ls-files'], {
|
|
178
|
+
encoding: 'utf8',
|
|
179
|
+
maxBuffer: 64 * 1024 * 1024
|
|
180
|
+
});
|
|
181
|
+
return stdout
|
|
182
|
+
.split('\n')
|
|
183
|
+
.map((line) => line.trim())
|
|
184
|
+
.filter((line) => line.length > 0)
|
|
185
|
+
.map((line) => normalizePath(line));
|
|
186
|
+
}
|
|
187
|
+
// Exported for S2's repair writer, which re-validates `exclude` on the
|
|
188
|
+
// way out so the read and write paths agree on what a valid config is.
|
|
189
|
+
export function assertStringArray(value, field, configPath) {
|
|
190
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
|
|
191
|
+
throw new Error(`codegraph config ${configPath}: "${field}" must be an array of strings`);
|
|
192
|
+
}
|
|
193
|
+
return value;
|
|
194
|
+
}
|
|
195
|
+
// Read `<projectRoot>/.codegraph/config.json` and return just the two
|
|
196
|
+
// glob lists the reconciler needs. Read-only: this module never writes
|
|
197
|
+
// that file.
|
|
198
|
+
export function readCodegraphExcludeConfig(projectRoot) {
|
|
199
|
+
const configPath = join(projectRoot, CODEGRAPH_DIR_NAME, CODEGRAPH_CONFIG_FILENAME);
|
|
200
|
+
const parsed = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
201
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
202
|
+
throw new Error(`codegraph config ${configPath}: expected a JSON object`);
|
|
203
|
+
}
|
|
204
|
+
const record = parsed;
|
|
205
|
+
return {
|
|
206
|
+
include: assertStringArray(record.include, 'include', configPath),
|
|
207
|
+
exclude: assertStringArray(record.exclude, 'exclude', configPath)
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
// Read-only entry point: resolve the project's tracked files + codegraph
|
|
211
|
+
// config from disk and reconcile them. S2's repair path consumes this
|
|
212
|
+
// result and writes the reduced `exclude` list back; S1 only computes.
|
|
213
|
+
export function reconcileCodegraphExcludeFromProject(projectRoot) {
|
|
214
|
+
const trackedFiles = readTrackedFiles(projectRoot);
|
|
215
|
+
const { include, exclude } = readCodegraphExcludeConfig(projectRoot);
|
|
216
|
+
return reconcileCodegraphExclude({ trackedFiles, include, exclude });
|
|
217
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { type CodegraphProcessRunner } from './codegraph-service.js';
|
|
2
|
+
/** Suffix of the byte-exact pre-repair copy kept next to the config. */
|
|
3
|
+
export declare const CODEGRAPH_CONFIG_BACKUP_SUFFIX = ".bak";
|
|
4
|
+
export type CodegraphExcludeRepairPlan = {
|
|
5
|
+
/** True when at least one rule would actually be dropped. */
|
|
6
|
+
readonly changed: boolean;
|
|
7
|
+
/** The `exclude` array after the removal. */
|
|
8
|
+
readonly exclude: readonly string[];
|
|
9
|
+
/** Rules actually present in `exclude` and dropped, in config order. */
|
|
10
|
+
readonly removedRules: readonly string[];
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Pure: given the current `exclude` list and the rules to drop, return
|
|
14
|
+
* the new list. No fs, no clock, no serialization.
|
|
15
|
+
*
|
|
16
|
+
* A rule named in `rulesToRemove` but absent from `exclude` is NOT
|
|
17
|
+
* invented — the result is a subset of the input, so a caller can
|
|
18
|
+
* never add a rule by accident. Removing an already-absent rule is a
|
|
19
|
+
* no-op, which is what makes the whole repair idempotent: feeding the
|
|
20
|
+
* repaired list back in yields `changed: false`.
|
|
21
|
+
*/
|
|
22
|
+
export declare function repairCodegraphExclude(input: {
|
|
23
|
+
readonly exclude: readonly string[];
|
|
24
|
+
readonly rulesToRemove: readonly string[];
|
|
25
|
+
}): CodegraphExcludeRepairPlan;
|
|
26
|
+
export type CodegraphExcludeRepairOutcome = {
|
|
27
|
+
readonly applied: false;
|
|
28
|
+
readonly reason: 'no-rules-to-remove';
|
|
29
|
+
readonly removedRules: readonly string[];
|
|
30
|
+
} | {
|
|
31
|
+
readonly applied: true;
|
|
32
|
+
readonly configPath: string;
|
|
33
|
+
readonly backupPath: string;
|
|
34
|
+
readonly removedRules: readonly string[];
|
|
35
|
+
readonly excludeCountBefore: number;
|
|
36
|
+
readonly excludeCountAfter: number;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Apply the repair to `<projectRoot>/.codegraph/config.json`.
|
|
40
|
+
*
|
|
41
|
+
* No-op (and no write, no mtime change, no backup) when
|
|
42
|
+
* `rulesToRemove` is empty. Otherwise: back up the original bytes to
|
|
43
|
+
* `config.json.bak`, then rewrite the file with `exclude` reduced by
|
|
44
|
+
* exactly the rules that were both requested and present.
|
|
45
|
+
*
|
|
46
|
+
* Throws only on real fs/parse failures — the caller decides whether
|
|
47
|
+
* that is fatal (`repair-exclude` → non-zero exit) or a surfaced
|
|
48
|
+
* warning (`init` → keep going, the init itself already succeeded).
|
|
49
|
+
*/
|
|
50
|
+
export declare function applyCodegraphExcludeRepair(projectRoot: string, rulesToRemove: readonly string[]): CodegraphExcludeRepairOutcome;
|
|
51
|
+
export type CodegraphExcludeRepairReport = {
|
|
52
|
+
/** True when the config was actually rewritten. */
|
|
53
|
+
readonly applied: boolean;
|
|
54
|
+
/** Rules dropped from `exclude`. */
|
|
55
|
+
readonly rulesRemoved: readonly string[];
|
|
56
|
+
/** Distinct tracked source files the dropped rules had been hiding. */
|
|
57
|
+
readonly filesRecovered: number;
|
|
58
|
+
/** Tracked files the config's `include` filter admits at all. */
|
|
59
|
+
readonly trackedSourceCount: number;
|
|
60
|
+
/** Absolute path of the rewritten config (empty when nothing was written). */
|
|
61
|
+
readonly configPath: string;
|
|
62
|
+
/** Byte-exact rollback copy (null when nothing was written). */
|
|
63
|
+
readonly backupPath: string | null;
|
|
64
|
+
/** True when the post-repair `codegraph index` finished successfully. */
|
|
65
|
+
readonly reindexed: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Non-null when the step could not run to completion (no git repo,
|
|
68
|
+
* missing config, malformed config, upstream index failure, …).
|
|
69
|
+
* Always reported, never swallowed — but never thrown either, so a
|
|
70
|
+
* successful `peaks codegraph init` is never undone by it.
|
|
71
|
+
*/
|
|
72
|
+
readonly warning: string | null;
|
|
73
|
+
};
|
|
74
|
+
export type CodegraphExcludeRepairOptions = {
|
|
75
|
+
/**
|
|
76
|
+
* When `false`, skip the follow-up `codegraph index` rebuild and
|
|
77
|
+
* return `reindexed: false` (with no warning — nothing went wrong).
|
|
78
|
+
*
|
|
79
|
+
* Set by callers that run their OWN index immediately after this
|
|
80
|
+
* step: the pre-dispatch preflight and the post-slice autorefresh
|
|
81
|
+
* both call `init` → `index`, and an index is expensive enough
|
|
82
|
+
* (5-30 s on this repo) that a fresh init must not pay for it twice.
|
|
83
|
+
*/
|
|
84
|
+
readonly reindex?: boolean;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Reconcile `projectRoot`'s exclude list, drop the rules that block
|
|
88
|
+
* tracked source files, and rebuild the index so the recovered files
|
|
89
|
+
* actually land in it.
|
|
90
|
+
*
|
|
91
|
+
* This is the ONE shared "after upstream init" self-heal: the CLI's
|
|
92
|
+
* fresh `peaks codegraph init`, the pre-dispatch preflight and the
|
|
93
|
+
* post-slice autorefresh all call it, so a fresh clone cannot end up
|
|
94
|
+
* with a peaks-loop marker stamped over an incomplete index that no
|
|
95
|
+
* later `init` would ever repair.
|
|
96
|
+
*
|
|
97
|
+
* Never throws. Every failure becomes a populated `warning` field, so
|
|
98
|
+
* the caller can surface it without the step being able to break the
|
|
99
|
+
* command it is attached to — including the fail-soft preflight and
|
|
100
|
+
* the never-throwing `refreshCodegraphAfterSlice`.
|
|
101
|
+
*/
|
|
102
|
+
export declare function repairCodegraphExcludeFromProject(projectRoot: string, runner?: CodegraphProcessRunner, options?: CodegraphExcludeRepairOptions): Promise<CodegraphExcludeRepairReport>;
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// src/services/codegraph/codegraph-exclude-repair.ts
|
|
2
|
+
//
|
|
3
|
+
// Slice S2 of `2026-09-12-codegraph-exclude-integrity` — the WRITE path.
|
|
4
|
+
//
|
|
5
|
+
// S1 computes `rulesToRemove`; this module applies it. Two callers are
|
|
6
|
+
// allowed to reach it and nothing else is:
|
|
7
|
+
//
|
|
8
|
+
// 1. `peaks codegraph init` — after a fresh upstream init (which
|
|
9
|
+
// always writes upstream's 99-rule default template), so a brand
|
|
10
|
+
// new clone / new machine ends up with a complete index instead
|
|
11
|
+
// of silently dropping tracked source files.
|
|
12
|
+
// 2. `peaks codegraph repair-exclude` — the explicit repair for a
|
|
13
|
+
// workspace that was initialized before this slice shipped, or
|
|
14
|
+
// whose config drifted.
|
|
15
|
+
//
|
|
16
|
+
// `status` and the doctor check deliberately do NOT live here: they
|
|
17
|
+
// read `codegraph-exclude-integrity.ts` and never write.
|
|
18
|
+
//
|
|
19
|
+
// Safety posture (the file being edited belongs to a THIRD-PARTY tool):
|
|
20
|
+
// - `rulesToRemove` comes from S1, which only ever lists a rule that
|
|
21
|
+
// actually blocks at least one git-tracked source file. A rule that
|
|
22
|
+
// matches nothing tracked is never dropped.
|
|
23
|
+
// - Nothing is written when `rulesToRemove` is empty — the config
|
|
24
|
+
// bytes, and its mtime, are untouched.
|
|
25
|
+
// - The original bytes are copied to `config.json.bak` before the
|
|
26
|
+
// rewrite, so a rollback is byte-exact.
|
|
27
|
+
// - The rewrite itself goes through a same-directory temp file plus
|
|
28
|
+
// `renameSync`, so the config is never observed half-written.
|
|
29
|
+
// - Every other key of the config survives verbatim, in its original
|
|
30
|
+
// position; only `exclude` changes.
|
|
31
|
+
import { randomBytes } from 'node:crypto';
|
|
32
|
+
import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
import { assertStringArray, CODEGRAPH_CONFIG_FILENAME } from './codegraph-exclude-reconciler.js';
|
|
35
|
+
import { CODEGRAPH_DIR_NAME, createCodegraphInvocation, executeCodegraphInvocation } from './codegraph-service.js';
|
|
36
|
+
import { inspectCodegraphExcludeIntegrity } from './codegraph-exclude-integrity.js';
|
|
37
|
+
/** Suffix of the byte-exact pre-repair copy kept next to the config. */
|
|
38
|
+
export const CODEGRAPH_CONFIG_BACKUP_SUFFIX = '.bak';
|
|
39
|
+
/**
|
|
40
|
+
* Pure: given the current `exclude` list and the rules to drop, return
|
|
41
|
+
* the new list. No fs, no clock, no serialization.
|
|
42
|
+
*
|
|
43
|
+
* A rule named in `rulesToRemove` but absent from `exclude` is NOT
|
|
44
|
+
* invented — the result is a subset of the input, so a caller can
|
|
45
|
+
* never add a rule by accident. Removing an already-absent rule is a
|
|
46
|
+
* no-op, which is what makes the whole repair idempotent: feeding the
|
|
47
|
+
* repaired list back in yields `changed: false`.
|
|
48
|
+
*/
|
|
49
|
+
export function repairCodegraphExclude(input) {
|
|
50
|
+
const removable = new Set(input.rulesToRemove);
|
|
51
|
+
// De-duplicated, order-preserving. A config that lists the same rule
|
|
52
|
+
// twice would otherwise be counted twice here, and this array is what
|
|
53
|
+
// the caller reports to the user as "rules removed".
|
|
54
|
+
const removedRules = [...new Set(input.exclude.filter((rule) => removable.has(rule)))];
|
|
55
|
+
if (removedRules.length === 0) {
|
|
56
|
+
return { changed: false, exclude: input.exclude, removedRules: [] };
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
changed: true,
|
|
60
|
+
exclude: input.exclude.filter((rule) => !removable.has(rule)),
|
|
61
|
+
removedRules
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Detect the indentation the config already uses so the rewrite keeps
|
|
66
|
+
* the file's shape instead of reformatting a third-party tool's file.
|
|
67
|
+
*
|
|
68
|
+
* A single-line (minified) config has no indentation to copy: `0` tells
|
|
69
|
+
* `JSON.stringify` to emit compact JSON, so the file comes back as the
|
|
70
|
+
* one-liner it went in as. `0` is NOT the same as "not set" here — an
|
|
71
|
+
* omitted indent would also be compact, but returning a number keeps the
|
|
72
|
+
* intent explicit at the call site.
|
|
73
|
+
*
|
|
74
|
+
* Falls back to two spaces when the file spans lines but has no indented
|
|
75
|
+
* member.
|
|
76
|
+
*/
|
|
77
|
+
function detectIndent(text) {
|
|
78
|
+
if (!text.trimEnd().includes('\n')) {
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
const match = /\n([ \t]+)"/.exec(text);
|
|
82
|
+
return match?.[1] ?? 2;
|
|
83
|
+
}
|
|
84
|
+
function serializeConfig(config, originalText) {
|
|
85
|
+
const body = JSON.stringify(config, null, detectIndent(originalText));
|
|
86
|
+
return originalText.endsWith('\n') ? `${body}\n` : body;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Write `content` to `filePath` atomically: same-directory temp file,
|
|
90
|
+
* then `renameSync` over the target.
|
|
91
|
+
*
|
|
92
|
+
* The file being written belongs to a THIRD-PARTY tool, so a crash or a
|
|
93
|
+
* full disk mid-`writeFileSync` must never leave a half-written config
|
|
94
|
+
* behind. `rename` within one directory is atomic, so a reader sees
|
|
95
|
+
* either the old bytes or the new ones, never a prefix of the new ones.
|
|
96
|
+
* The temp file lives next to the target (same directory ⇒ same
|
|
97
|
+
* filesystem ⇒ the rename cannot degrade to a cross-device copy) and is
|
|
98
|
+
* removed if the write or the rename fails.
|
|
99
|
+
*
|
|
100
|
+
* N5: the temp name carries the pid plus 6 random bytes. A FIXED
|
|
101
|
+
* `${filePath}.tmp` is single-writer only, and this writer has three
|
|
102
|
+
* reachable concurrent callers — a fresh `peaks codegraph init` (which
|
|
103
|
+
* repairs then indexes), the pre-dispatch preflight, and the post-slice
|
|
104
|
+
* autorefresh. Two overlapping writers sharing one temp name let one
|
|
105
|
+
* `renameSync` publish a file the other was still writing, which is
|
|
106
|
+
* exactly the half-written config the temp file exists to prevent. The
|
|
107
|
+
* suffix keeps the temp in the SAME directory, so the rename stays
|
|
108
|
+
* within one filesystem and therefore stays atomic.
|
|
109
|
+
*/
|
|
110
|
+
function writeConfigAtomic(filePath, content) {
|
|
111
|
+
const tempPath = `${filePath}.${String(process.pid)}.${randomBytes(6).toString('hex')}.tmp`;
|
|
112
|
+
try {
|
|
113
|
+
writeFileSync(tempPath, content, 'utf8');
|
|
114
|
+
renameSync(tempPath, filePath);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
rmSync(tempPath, { force: true });
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Apply the repair to `<projectRoot>/.codegraph/config.json`.
|
|
123
|
+
*
|
|
124
|
+
* No-op (and no write, no mtime change, no backup) when
|
|
125
|
+
* `rulesToRemove` is empty. Otherwise: back up the original bytes to
|
|
126
|
+
* `config.json.bak`, then rewrite the file with `exclude` reduced by
|
|
127
|
+
* exactly the rules that were both requested and present.
|
|
128
|
+
*
|
|
129
|
+
* Throws only on real fs/parse failures — the caller decides whether
|
|
130
|
+
* that is fatal (`repair-exclude` → non-zero exit) or a surfaced
|
|
131
|
+
* warning (`init` → keep going, the init itself already succeeded).
|
|
132
|
+
*/
|
|
133
|
+
export function applyCodegraphExcludeRepair(projectRoot, rulesToRemove) {
|
|
134
|
+
if (rulesToRemove.length === 0) {
|
|
135
|
+
return { applied: false, reason: 'no-rules-to-remove', removedRules: [] };
|
|
136
|
+
}
|
|
137
|
+
const configPath = join(projectRoot, CODEGRAPH_DIR_NAME, CODEGRAPH_CONFIG_FILENAME);
|
|
138
|
+
const originalText = readFileSync(configPath, 'utf8');
|
|
139
|
+
const parsed = JSON.parse(originalText);
|
|
140
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
141
|
+
throw new Error(`codegraph config ${configPath}: expected a JSON object`);
|
|
142
|
+
}
|
|
143
|
+
const record = parsed;
|
|
144
|
+
const exclude = assertStringArray(record.exclude, 'exclude', configPath);
|
|
145
|
+
const plan = repairCodegraphExclude({ exclude, rulesToRemove });
|
|
146
|
+
if (!plan.changed) {
|
|
147
|
+
return { applied: false, reason: 'no-rules-to-remove', removedRules: [] };
|
|
148
|
+
}
|
|
149
|
+
const backupPath = `${configPath}${CODEGRAPH_CONFIG_BACKUP_SUFFIX}`;
|
|
150
|
+
writeFileSync(backupPath, originalText, 'utf8');
|
|
151
|
+
// Spread first, then replace `exclude` — every other key keeps its
|
|
152
|
+
// original value AND its original position in the serialized object.
|
|
153
|
+
writeConfigAtomic(configPath, serializeConfig({ ...record, exclude: plan.exclude }, originalText));
|
|
154
|
+
return {
|
|
155
|
+
applied: true,
|
|
156
|
+
configPath,
|
|
157
|
+
backupPath,
|
|
158
|
+
removedRules: plan.removedRules,
|
|
159
|
+
excludeCountBefore: exclude.length,
|
|
160
|
+
excludeCountAfter: plan.exclude.length
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function errorMessage(error) {
|
|
164
|
+
return error instanceof Error ? error.message : String(error);
|
|
165
|
+
}
|
|
166
|
+
function emptyRepairReport(warning) {
|
|
167
|
+
return {
|
|
168
|
+
applied: false,
|
|
169
|
+
rulesRemoved: [],
|
|
170
|
+
filesRecovered: 0,
|
|
171
|
+
trackedSourceCount: 0,
|
|
172
|
+
configPath: '',
|
|
173
|
+
backupPath: null,
|
|
174
|
+
reindexed: false,
|
|
175
|
+
warning
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Reconcile `projectRoot`'s exclude list, drop the rules that block
|
|
180
|
+
* tracked source files, and rebuild the index so the recovered files
|
|
181
|
+
* actually land in it.
|
|
182
|
+
*
|
|
183
|
+
* This is the ONE shared "after upstream init" self-heal: the CLI's
|
|
184
|
+
* fresh `peaks codegraph init`, the pre-dispatch preflight and the
|
|
185
|
+
* post-slice autorefresh all call it, so a fresh clone cannot end up
|
|
186
|
+
* with a peaks-loop marker stamped over an incomplete index that no
|
|
187
|
+
* later `init` would ever repair.
|
|
188
|
+
*
|
|
189
|
+
* Never throws. Every failure becomes a populated `warning` field, so
|
|
190
|
+
* the caller can surface it without the step being able to break the
|
|
191
|
+
* command it is attached to — including the fail-soft preflight and
|
|
192
|
+
* the never-throwing `refreshCodegraphAfterSlice`.
|
|
193
|
+
*/
|
|
194
|
+
export async function repairCodegraphExcludeFromProject(projectRoot, runner, options = {}) {
|
|
195
|
+
let integrity;
|
|
196
|
+
try {
|
|
197
|
+
integrity = inspectCodegraphExcludeIntegrity(projectRoot);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
return emptyRepairReport(`codegraph exclude reconcile skipped: ${errorMessage(error)}`);
|
|
201
|
+
}
|
|
202
|
+
if (integrity.rulesToRemove.length === 0) {
|
|
203
|
+
return {
|
|
204
|
+
applied: false,
|
|
205
|
+
rulesRemoved: [],
|
|
206
|
+
filesRecovered: 0,
|
|
207
|
+
trackedSourceCount: integrity.trackedSourceCount,
|
|
208
|
+
configPath: integrity.configPath,
|
|
209
|
+
backupPath: null,
|
|
210
|
+
reindexed: false,
|
|
211
|
+
warning: null
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
let outcome;
|
|
215
|
+
try {
|
|
216
|
+
outcome = applyCodegraphExcludeRepair(projectRoot, integrity.rulesToRemove);
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
return emptyRepairReport(`codegraph exclude repair failed for ${integrity.configPath}: ${errorMessage(error)}`);
|
|
220
|
+
}
|
|
221
|
+
if (!outcome.applied) {
|
|
222
|
+
return {
|
|
223
|
+
applied: false,
|
|
224
|
+
rulesRemoved: [],
|
|
225
|
+
filesRecovered: 0,
|
|
226
|
+
trackedSourceCount: integrity.trackedSourceCount,
|
|
227
|
+
configPath: integrity.configPath,
|
|
228
|
+
backupPath: null,
|
|
229
|
+
reindexed: false,
|
|
230
|
+
warning: null
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const base = {
|
|
234
|
+
applied: true,
|
|
235
|
+
rulesRemoved: outcome.removedRules,
|
|
236
|
+
filesRecovered: integrity.excludedTrackedCount,
|
|
237
|
+
trackedSourceCount: integrity.trackedSourceCount,
|
|
238
|
+
configPath: outcome.configPath,
|
|
239
|
+
backupPath: outcome.backupPath
|
|
240
|
+
};
|
|
241
|
+
if (options.reindex === false) {
|
|
242
|
+
// The caller indexes immediately after this returns, so doing it
|
|
243
|
+
// here too would index the same tree twice for no extra coverage.
|
|
244
|
+
return { ...base, reindexed: false, warning: null };
|
|
245
|
+
}
|
|
246
|
+
// The recovered files are on disk but not in the index yet. Rebuild
|
|
247
|
+
// it now — that is the whole point of repairing at init time.
|
|
248
|
+
try {
|
|
249
|
+
const result = await executeCodegraphInvocation(createCodegraphInvocation({ subcommand: 'index', project: projectRoot, quiet: true }), runner);
|
|
250
|
+
if (result.exitCode !== 0) {
|
|
251
|
+
return {
|
|
252
|
+
...base,
|
|
253
|
+
reindexed: false,
|
|
254
|
+
warning: `codegraph exclude repaired (${outcome.removedRules.length} rule(s) removed) but the follow-up index failed (exit ${String(result.exitCode)}); run \`peaks codegraph index --project <root>\`.`
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
return { ...base, reindexed: true, warning: null };
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
return {
|
|
261
|
+
...base,
|
|
262
|
+
reindexed: false,
|
|
263
|
+
warning: `codegraph exclude repaired (${outcome.removedRules.length} rule(s) removed) but the follow-up index could not run: ${errorMessage(error)}`
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
import { mkdirSync } from 'node:fs';
|
|
34
34
|
import { CODEGRAPH_DIR_NAME, createCodegraphInvocation, defaultCodegraphInitGuard, executeCodegraphInvocation, isCodegraphInitialized, writeCodegraphMarker, } from './codegraph-service.js';
|
|
35
35
|
import { defaultCodegraphProcessRunner } from './codegraph-process-runner.js';
|
|
36
|
+
import { repairCodegraphExcludeFromProject } from './codegraph-exclude-repair.js';
|
|
36
37
|
/** Cap for the directory histogram in the rendered structure block. */
|
|
37
38
|
export const CODEGRAPH_STRUCTURE_MAX_DIRS = 40;
|
|
38
39
|
/** Cap for bare root files listed in the rendered structure block. */
|
|
@@ -208,6 +209,17 @@ export async function buildCodegraphPreflightBlock(projectRoot, runner) {
|
|
|
208
209
|
catch {
|
|
209
210
|
// Best-effort: a marker-write failure must not undo the init.
|
|
210
211
|
}
|
|
212
|
+
// Upstream init just wrote its 99-rule default `exclude` template,
|
|
213
|
+
// some of which block tracked source files. Without this, a fresh
|
|
214
|
+
// clone is initialized HERE, by the first dispatch's preflight —
|
|
215
|
+
// marker stamped over an incomplete index — and every later
|
|
216
|
+
// `peaks codegraph init` hits `noop-already-peaks-loop`, so the
|
|
217
|
+
// CLI's own self-heal becomes unreachable. Same shared helper, so
|
|
218
|
+
// all three init landing sites behave identically.
|
|
219
|
+
//
|
|
220
|
+
// Never throws, and `reindex: false` because the index call below
|
|
221
|
+
// covers the recovered files anyway.
|
|
222
|
+
await repairCodegraphExcludeFromProject(projectRoot, processRunner, { reindex: false });
|
|
211
223
|
}
|
|
212
224
|
catch (error) {
|
|
213
225
|
return { available: false, note: `codegraph init unavailable: ${errorMessage(error)}` };
|
|
@@ -13,7 +13,11 @@ const NUMERIC_FLAG_NAMES = ['limit', 'maxDepth'];
|
|
|
13
13
|
const COMMON_OPTION_KEYS = ['subcommand', 'project'];
|
|
14
14
|
const ALLOWED_OPTIONS_BY_SUBCOMMAND = {
|
|
15
15
|
status: [],
|
|
16
|
-
init
|
|
16
|
+
// Upstream `init` takes NO flags. The `--yes` we used to whitelist
|
|
17
|
+
// here was passed straight through to `@colbymchenry/codegraph`, which
|
|
18
|
+
// rejects it with CODEGRAPH_COMMAND_FAILED — and upstream init never
|
|
19
|
+
// prompts, so there was nothing for it to answer.
|
|
20
|
+
init: [],
|
|
17
21
|
index: ['force', 'quiet'],
|
|
18
22
|
query: ['search', 'json', 'limit'],
|
|
19
23
|
files: ['json', 'maxDepth'],
|
|
@@ -120,9 +124,6 @@ function buildCommandArgs(options, projectRoot) {
|
|
|
120
124
|
if (options.subcommand === 'affected') {
|
|
121
125
|
args.push(...buildAffectedFileArgs(projectRoot, options.files));
|
|
122
126
|
}
|
|
123
|
-
if (options.yes === true) {
|
|
124
|
-
args.push('--yes');
|
|
125
|
-
}
|
|
126
127
|
if (options.force === true) {
|
|
127
128
|
args.push('--force');
|
|
128
129
|
}
|