peaks-loop 4.0.43 → 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.
- package/CHANGELOG.md +32 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/dist/cli/commands/codegraph-commands.js +191 -6
- package/dist/cli/commands/final-review-commands.d.ts +34 -10
- package/dist/cli/commands/final-review-commands.js +130 -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 +154 -0
- package/dist/services/final-review/final-review-service.js +621 -7
- package/dist/services/final-review/index.d.ts +1 -1
- package/dist/services/final-review/index.js +1 -1
- 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 +43 -32
|
@@ -5,6 +5,36 @@ import { readSharedChannel, writeSharedEntry, SHARED_CHANNEL_SOFT_VALUE_WARN } f
|
|
|
5
5
|
import { writeLogEntry } from '../../services/log/logger.js';
|
|
6
6
|
import { getCurrentSessionId } from '../../services/skills/skill-presence-service.js';
|
|
7
7
|
import { summarizeBatchResults } from './sub-agent-shared.js';
|
|
8
|
+
/** The one selection rule `--request-id` and `--batch` now share. */
|
|
9
|
+
export const FINALIZE_SELECTION_RULE = 'prefer status=queued; among those, newest by record createdAt (then filename); ' +
|
|
10
|
+
'no queued match means nothing is finalized';
|
|
11
|
+
/**
|
|
12
|
+
* N3 — pick the record `finalize --request-id` should act on.
|
|
13
|
+
*
|
|
14
|
+
* The branch this replaces `break`ed on the FIRST file whose record carried
|
|
15
|
+
* the requestId and never looked at `status`. With a re-dispatched request
|
|
16
|
+
* (this session holds six records for `2026-09-12-defect-remediation`) it
|
|
17
|
+
* always resolved to the OLDEST one — the already-`done` RD record — so
|
|
18
|
+
* finalizing reported success while the newer `queued` QA record stayed
|
|
19
|
+
* queued forever. `--batch` never had that bug: it filters on `queued`.
|
|
20
|
+
*
|
|
21
|
+
* Both branches are now the same rule. Return `null` when nothing is queued:
|
|
22
|
+
* a record that already left `queued` is precisely the one that must NOT be
|
|
23
|
+
* re-finalized, so the caller reports the survivors instead of touching one.
|
|
24
|
+
*/
|
|
25
|
+
export function selectFinalizeTarget(candidates) {
|
|
26
|
+
const queued = candidates.filter(candidate => candidate.status === 'queued');
|
|
27
|
+
if (queued.length === 0)
|
|
28
|
+
return null;
|
|
29
|
+
return [...queued].sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.recordPath.localeCompare(a.recordPath))[0];
|
|
30
|
+
}
|
|
31
|
+
/** Why a candidate was not the chosen one — reported, never guessed at. */
|
|
32
|
+
export function describeFinalizeRejection(candidate, chosen) {
|
|
33
|
+
if (candidate.status !== 'queued' || chosen === null) {
|
|
34
|
+
return 'status is ' + candidate.status;
|
|
35
|
+
}
|
|
36
|
+
return 'superseded by the newer queued record ' + chosen.recordPath;
|
|
37
|
+
}
|
|
8
38
|
export function registerShareCommand(parent, io) {
|
|
9
39
|
addJsonOption(parent
|
|
10
40
|
.command('share')
|
|
@@ -329,6 +359,31 @@ export function registerFinalizeCommand(parent, io) {
|
|
|
329
359
|
const finalized = [];
|
|
330
360
|
const skipped = [];
|
|
331
361
|
const errors = [];
|
|
362
|
+
let selection = null;
|
|
363
|
+
/**
|
|
364
|
+
* N2 — one unreadable record must not abort the sweep.
|
|
365
|
+
*
|
|
366
|
+
* Skipping `active-dispatches.json` and `batch-*.counter.json` by
|
|
367
|
+
* FILENAME removed the two non-record files that happened to be in the
|
|
368
|
+
* directory, but a single stale or foreign `dispatch-*.json` (an old
|
|
369
|
+
* `version`, hand-edited JSON, a truncated write) still made
|
|
370
|
+
* `readRecord` throw from OUTSIDE any try/catch — and the throw
|
|
371
|
+
* escaped to the action's outer handler, so `--request-id` AND
|
|
372
|
+
* `--batch` both died with `FINALIZE_ERROR` and exit 1 without
|
|
373
|
+
* touching a single healthy record.
|
|
374
|
+
*
|
|
375
|
+
* A record that cannot be read is now reported in `errors[]` and
|
|
376
|
+
* skipped; every other record is processed as before.
|
|
377
|
+
*/
|
|
378
|
+
const tryReadRecord = (recordPath) => {
|
|
379
|
+
try {
|
|
380
|
+
return readRecord(recordPath);
|
|
381
|
+
}
|
|
382
|
+
catch (e) {
|
|
383
|
+
errors.push({ recordPath, error: getErrorMessage(e) });
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
};
|
|
332
387
|
const applyOutcome = (recordPath, rid) => {
|
|
333
388
|
markCompleted({ recordPath, now: () => new Date(), status: mapped.status, outcome: mapped.outcome, projectRoot });
|
|
334
389
|
finalized.push({ recordPath, requestId: rid, status: mapped.status });
|
|
@@ -352,29 +407,59 @@ export function registerFinalizeCommand(parent, io) {
|
|
|
352
407
|
const fs2 = await import('node:fs');
|
|
353
408
|
const path2 = await import('node:path');
|
|
354
409
|
const dir = path2.resolve(projectRoot, '.peaks', '_sub_agents', sessionId);
|
|
355
|
-
|
|
410
|
+
const candidates = [];
|
|
356
411
|
if (fs2.existsSync(dir)) {
|
|
357
412
|
for (const f of fs2.readdirSync(dir)) {
|
|
358
|
-
|
|
413
|
+
// Only dispatch records are readable records. The session
|
|
414
|
+
// directory also holds `active-dispatches.json` (an index)
|
|
415
|
+
// and `batch-<uuid>.counter.json` (batch counters); neither
|
|
416
|
+
// carries a `version` field, so `readRecord` on them throws
|
|
417
|
+
// `Dispatch record version mismatch ... got undefined`. The
|
|
418
|
+
// `--batch` branch below has always used this same filter.
|
|
419
|
+
if (!f.startsWith('dispatch-') || !f.endsWith('.json'))
|
|
359
420
|
continue;
|
|
360
421
|
const p = path2.join(dir, f);
|
|
361
|
-
const r =
|
|
362
|
-
if (r.requestId
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
422
|
+
const r = tryReadRecord(p);
|
|
423
|
+
if (r === null || r.requestId !== options.requestId)
|
|
424
|
+
continue;
|
|
425
|
+
candidates.push({
|
|
426
|
+
recordPath: p,
|
|
427
|
+
requestId: r.requestId,
|
|
428
|
+
status: r.status,
|
|
429
|
+
createdAt: r.createdAt
|
|
430
|
+
});
|
|
366
431
|
}
|
|
367
432
|
}
|
|
368
|
-
if (
|
|
433
|
+
if (candidates.length === 0) {
|
|
369
434
|
printResult(io, fail('sub-agent.finalize', 'RECORD_NOT_FOUND', 'No dispatch record for requestId=' + options.requestId, { ok: false }, ['Check --request-id matches the dispatch envelope.']), asJson);
|
|
370
435
|
process.exitCode = 1;
|
|
371
436
|
return;
|
|
372
437
|
}
|
|
373
|
-
|
|
374
|
-
|
|
438
|
+
// N3: same rule as `--batch` — see `selectFinalizeTarget`.
|
|
439
|
+
const chosen = selectFinalizeTarget(candidates);
|
|
440
|
+
selection = {
|
|
441
|
+
requestId: options.requestId,
|
|
442
|
+
rule: FINALIZE_SELECTION_RULE,
|
|
443
|
+
matched: candidates.length,
|
|
444
|
+
chosen: chosen?.recordPath ?? null,
|
|
445
|
+
rejected: candidates
|
|
446
|
+
.filter(candidate => candidate !== chosen)
|
|
447
|
+
.map(candidate => ({
|
|
448
|
+
recordPath: candidate.recordPath,
|
|
449
|
+
status: candidate.status,
|
|
450
|
+
reason: describeFinalizeRejection(candidate, chosen)
|
|
451
|
+
}))
|
|
452
|
+
};
|
|
453
|
+
for (const rejected of selection.rejected) {
|
|
454
|
+
skipped.push({ recordPath: rejected.recordPath, reason: rejected.reason });
|
|
375
455
|
}
|
|
376
|
-
|
|
377
|
-
|
|
456
|
+
if (chosen !== null) {
|
|
457
|
+
try {
|
|
458
|
+
applyOutcome(chosen.recordPath, chosen.requestId);
|
|
459
|
+
}
|
|
460
|
+
catch (e) {
|
|
461
|
+
errors.push({ recordPath: chosen.recordPath, error: getErrorMessage(e) });
|
|
462
|
+
}
|
|
378
463
|
}
|
|
379
464
|
}
|
|
380
465
|
else {
|
|
@@ -386,7 +471,9 @@ export function registerFinalizeCommand(parent, io) {
|
|
|
386
471
|
if (!f.startsWith('dispatch-') || !f.endsWith('.json'))
|
|
387
472
|
continue;
|
|
388
473
|
const p = path2.join(dir, f);
|
|
389
|
-
const r =
|
|
474
|
+
const r = tryReadRecord(p);
|
|
475
|
+
if (r === null)
|
|
476
|
+
continue;
|
|
390
477
|
if (r.batchId !== options.batch)
|
|
391
478
|
continue;
|
|
392
479
|
if (r.status !== 'queued') {
|
|
@@ -402,7 +489,20 @@ export function registerFinalizeCommand(parent, io) {
|
|
|
402
489
|
}
|
|
403
490
|
}
|
|
404
491
|
}
|
|
405
|
-
|
|
492
|
+
const hints = [];
|
|
493
|
+
if (errors.length > 0) {
|
|
494
|
+
hints.push('Re-run after fixing; unreadable records are listed in errors[] and were skipped, not fatal.');
|
|
495
|
+
}
|
|
496
|
+
else if (finalized.length === 0 && skipped.length > 0) {
|
|
497
|
+
hints.push('Nothing was finalized: every matching record had already left `queued`. See skipped[] for each record\'s status.');
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
hints.push('All targeted records transitioned out of queued.');
|
|
501
|
+
}
|
|
502
|
+
if (selection !== null) {
|
|
503
|
+
hints.push(`--request-id selection (${selection.rule}): chose ${selection.chosen ?? '(none)'} of ${selection.matched} matching record(s); ${selection.rejected.length} rejected.`);
|
|
504
|
+
}
|
|
505
|
+
printResult(io, ok('sub-agent.finalize', { finalized, skipped, errors, selection, sessionId, outcome }, errors.length > 0 ? [errors.length + ' failed'] : [], hints), asJson);
|
|
406
506
|
if (errors.length > 0)
|
|
407
507
|
process.exitCode = 1;
|
|
408
508
|
}
|
|
@@ -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;
|
|
@@ -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
|
+
}
|