@titan-design/code-graph 0.3.0 → 0.4.0
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/README.md +43 -3
- package/dist/index.d.ts +121 -1
- package/dist/index.js +323 -6
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -44,10 +44,13 @@ Ported with TP-250: package partition quality (`src/analysis/partition-quality.t
|
|
|
44
44
|
snapshot pruning (`src/prune.ts`). See [Partition quality](#partition-quality) and
|
|
45
45
|
[Pruning snapshots](#pruning-snapshots).
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
Ported with TP-130: community detection and the convention layer (`src/conventions/`). See
|
|
48
|
+
[Conventions](#conventions). codewatch keeps the CLI command, the `claude -p` summarizer, and
|
|
49
|
+
the MCP and read-API wiring.
|
|
49
50
|
|
|
50
|
-
|
|
51
|
+
Deferred, still in codewatch, a follow-up on this package rather than a change to it:
|
|
52
|
+
|
|
53
|
+
- Reuse-delta reporting over a finished snapshot.
|
|
51
54
|
|
|
52
55
|
Python support is new here rather than ported. codewatch walked TypeScript only; the parser
|
|
53
56
|
already had the grammar. The Python extractor is deliberately narrower than the ts-morph one:
|
|
@@ -364,6 +367,43 @@ The domain tables declare no foreign key, so `CodeGraphStore.deleteSnapshots` cl
|
|
|
364
367
|
`SNAPSHOT_SCOPED_TABLES` itself rather than relying on a cascade. `blob_cache` is
|
|
365
368
|
content-addressed and not snapshot-scoped, so a prune never drops a cached embedding.
|
|
366
369
|
|
|
370
|
+
## Conventions
|
|
371
|
+
|
|
372
|
+
"How does this repo do X, and where does code like this belong?" Ported from codewatch's
|
|
373
|
+
unmerged C-88 branch (TP-130). The layer cuts the barrel-resolved file graph into a few coarse
|
|
374
|
+
areas, has an injected summarizer describe each one, and ranks areas against a question by
|
|
375
|
+
embedding similarity. Verified against this release on this repo's `packages/`, with a fake
|
|
376
|
+
summarizer:
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
import { findConventions, getConventionMap, summarizeConventions } from "@titan-design/code-graph";
|
|
380
|
+
|
|
381
|
+
const summarizer = { model: "claude:sonnet", summarize: (prompt: string) => callYourLlm(prompt) };
|
|
382
|
+
await summarizeConventions(store, snapshotId, summarizer);
|
|
383
|
+
// { coverage: { files: 713, grouped: 513, areas: 24, summarized: 24 }, newlySummarized: 24, reused: 0, … }
|
|
384
|
+
// a second run: { newlySummarized: 0, reused: 24 }
|
|
385
|
+
|
|
386
|
+
getConventionMap(store, snapshotId, "claude:sonnet"); // the same areas, stored summaries only
|
|
387
|
+
await findConventions(store, snapshotId, "how are CLI commands registered?", embedder, "claude:sonnet");
|
|
388
|
+
// { matches: [ { label, summary, files: [ …up to 5 ], size, score }, … up to 3 ] }
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
- **The cut.** `detectCommunities` is greedy modularity (Clauset-Newman-Moore), not Leiden. It
|
|
392
|
+
is deterministic without a seed: ties resolve by sorted id. `targetCount` keeps merging past
|
|
393
|
+
the natural modularity stop until that many communities remain, and a size cap of twice the
|
|
394
|
+
ideal share keeps a dense repo from collapsing into one area. The default target is one area
|
|
395
|
+
per 25 files, clamped to 6..40. Areas under `minSize` (default 3) files are left unsummarized.
|
|
396
|
+
Disconnected components never merge, so the component count is the floor.
|
|
397
|
+
- **Only the coarse level is summarized.** LLM cost is one call per area, and each summary is
|
|
398
|
+
stored in `blob_cache` under `code-graph/community-summary`, keyed by `summarizer.model` and a
|
|
399
|
+
hash of the prompt. The prompt carries the member files and their key exported signatures, so
|
|
400
|
+
an area whose membership and signatures did not change is a cache hit in any snapshot. On
|
|
401
|
+
this repo a finer cut (`targetCount: 60`) still reused 8 of its 35 areas.
|
|
402
|
+
- **The package ships no LLM client.** `Summarizer` is `{ model, summarize(prompt) }`; the
|
|
403
|
+
product supplies it. `getConventionMap` and `findConventions` never call it. `findConventions`
|
|
404
|
+
throws when no summary is stored for the model, and returns candidates with scores, not
|
|
405
|
+
verdicts. Summary vectors go through the same embedding cache as similar symbols.
|
|
406
|
+
|
|
367
407
|
## Git history
|
|
368
408
|
|
|
369
409
|
Ported in TP-126, strictly as codewatch had it: churn over rolling windows, first-seen dates,
|
package/dist/index.d.ts
CHANGED
|
@@ -1432,4 +1432,124 @@ declare function tryEmbedSnapshot(store: CodeGraphStore, snapshotId: number, emb
|
|
|
1432
1432
|
*/
|
|
1433
1433
|
declare function findSimilarCapability(store: CodeGraphStore, snapshotId: number, query: string, embedder: Embedder, opts?: FindSimilarOptions): Promise<SimilarResult>;
|
|
1434
1434
|
|
|
1435
|
-
|
|
1435
|
+
/** A text-summarization backend the product injects; `model` keys the stored summaries. */
|
|
1436
|
+
interface Summarizer {
|
|
1437
|
+
readonly model: string;
|
|
1438
|
+
summarize(prompt: string): Promise<string>;
|
|
1439
|
+
}
|
|
1440
|
+
interface ConventionSymbol {
|
|
1441
|
+
name: string;
|
|
1442
|
+
signature: string;
|
|
1443
|
+
purpose?: string;
|
|
1444
|
+
}
|
|
1445
|
+
interface ConventionArea {
|
|
1446
|
+
/** Stable over membership: `area-` plus a hash of the member file list. */
|
|
1447
|
+
id: string;
|
|
1448
|
+
/** Dominant directory of the members. */
|
|
1449
|
+
label: string;
|
|
1450
|
+
/** Member files, most-connected first. */
|
|
1451
|
+
files: string[];
|
|
1452
|
+
size: number;
|
|
1453
|
+
topSymbols: ConventionSymbol[];
|
|
1454
|
+
/** Hash of the summarizer prompt, which is the summary-cache key. */
|
|
1455
|
+
contentHash: string;
|
|
1456
|
+
summary?: string;
|
|
1457
|
+
}
|
|
1458
|
+
interface ConventionCoverage {
|
|
1459
|
+
/** Non-generated indexed files in the snapshot. */
|
|
1460
|
+
files: number;
|
|
1461
|
+
/** Files inside a kept area; the rest are too small or isolated to summarize. */
|
|
1462
|
+
grouped: number;
|
|
1463
|
+
areas: number;
|
|
1464
|
+
summarized: number;
|
|
1465
|
+
}
|
|
1466
|
+
interface ConventionMap {
|
|
1467
|
+
model: string;
|
|
1468
|
+
coverage: ConventionCoverage;
|
|
1469
|
+
areas: ConventionArea[];
|
|
1470
|
+
}
|
|
1471
|
+
interface SummarizeConventionsResult extends ConventionMap {
|
|
1472
|
+
/** Cache misses sent to the summarizer on this call. */
|
|
1473
|
+
newlySummarized: number;
|
|
1474
|
+
/** Areas whose summary was already stored. */
|
|
1475
|
+
reused: number;
|
|
1476
|
+
}
|
|
1477
|
+
interface ConventionMatch {
|
|
1478
|
+
id: string;
|
|
1479
|
+
label: string;
|
|
1480
|
+
summary: string;
|
|
1481
|
+
files: string[];
|
|
1482
|
+
size: number;
|
|
1483
|
+
/** Cosine similarity of the query to the area summary, in [-1, 1]. */
|
|
1484
|
+
score: number;
|
|
1485
|
+
}
|
|
1486
|
+
interface ConventionQueryResult {
|
|
1487
|
+
query: string;
|
|
1488
|
+
model: string;
|
|
1489
|
+
embeddingModel: string;
|
|
1490
|
+
coverage: ConventionCoverage;
|
|
1491
|
+
matches: ConventionMatch[];
|
|
1492
|
+
}
|
|
1493
|
+
/** The cut level: how coarse the partition is and which areas are kept. */
|
|
1494
|
+
interface ConventionOptions {
|
|
1495
|
+
/** Coarse community count to merge down to; default scales with repo size. */
|
|
1496
|
+
targetCount?: number;
|
|
1497
|
+
/** Areas smaller than this stay ungrouped (not summarized). Default 3. */
|
|
1498
|
+
minSize?: number;
|
|
1499
|
+
}
|
|
1500
|
+
interface FindConventionsOptions extends ConventionOptions {
|
|
1501
|
+
/** Matches returned. Default 3. */
|
|
1502
|
+
limit?: number;
|
|
1503
|
+
}
|
|
1504
|
+
interface ConventionCorpus {
|
|
1505
|
+
areas: ConventionArea[];
|
|
1506
|
+
/** Summarizer prompt per area, keyed by the area's `contentHash`. */
|
|
1507
|
+
prompts: Map<string, string>;
|
|
1508
|
+
coverage: ConventionCoverage;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* Greedy-modularity (Clauset-Newman-Moore) community detection over the
|
|
1513
|
+
* undirected file dependency graph. Deterministic: ties and labels resolve
|
|
1514
|
+
* by sorted node id, so the same graph always yields the same partition.
|
|
1515
|
+
*
|
|
1516
|
+
* Two optional controls shape the C-88 capability-altitude cut, in opposite
|
|
1517
|
+
* directions: `maxSize` skips any merge that would grow a community past it
|
|
1518
|
+
* (greedy CNM otherwise snowballs a dense graph into one giant blob — the
|
|
1519
|
+
* cap shapes formation instead of trying to split after the fact), and
|
|
1520
|
+
* `targetCount` keeps merging past the natural modularity stop (taking the
|
|
1521
|
+
* least-bad allowed merge) while more communities than that remain, so a
|
|
1522
|
+
* sparse fragmented graph still coarsens. Disconnected components can never
|
|
1523
|
+
* merge, so the floor is the component count.
|
|
1524
|
+
*/
|
|
1525
|
+
declare function detectCommunities(fileIds: readonly string[], edges: readonly GraphEdge[], opts?: {
|
|
1526
|
+
targetCount?: number;
|
|
1527
|
+
maxSize?: number;
|
|
1528
|
+
}): Map<string, string[]>;
|
|
1529
|
+
|
|
1530
|
+
/** Coarse capability-altitude cut: about one area per 25 files, clamped to 6..40. */
|
|
1531
|
+
declare function defaultTargetCount(fileCount: number): number;
|
|
1532
|
+
/**
|
|
1533
|
+
* The deterministic half of the convention layer: partition the barrel-resolved
|
|
1534
|
+
* file graph into coarse areas and build each area's summarizer prompt and cache key.
|
|
1535
|
+
*/
|
|
1536
|
+
declare function buildConventionAreas(store: CodeGraphStore, snapshotId: number, opts?: ConventionOptions): ConventionCorpus;
|
|
1537
|
+
|
|
1538
|
+
/** The blob_cache namespace for area summaries; the model column holds `summarizer.model`. */
|
|
1539
|
+
declare const COMMUNITY_SUMMARY_NAMESPACE = "code-graph/community-summary";
|
|
1540
|
+
/**
|
|
1541
|
+
* Generate or reuse the summary of every area. Only cache misses reach the
|
|
1542
|
+
* summarizer, so cost scales with what structurally changed, not with repo size.
|
|
1543
|
+
*/
|
|
1544
|
+
declare function summarizeConventions(store: CodeGraphStore, snapshotId: number, summarizer: Summarizer, opts?: ConventionOptions): Promise<SummarizeConventionsResult>;
|
|
1545
|
+
/** Read-only view: the partition plus whatever summaries are already stored. Never calls a summarizer. */
|
|
1546
|
+
declare function getConventionMap(store: CodeGraphStore, snapshotId: number, model: string, opts?: ConventionOptions): ConventionMap;
|
|
1547
|
+
|
|
1548
|
+
/**
|
|
1549
|
+
* "How does this repo do X?": rank summarized areas by similarity of their
|
|
1550
|
+
* summary to the question. Returns candidates, not verdicts. Summary vectors
|
|
1551
|
+
* share the content-addressed embedding cache with the symbol layer.
|
|
1552
|
+
*/
|
|
1553
|
+
declare function findConventions(store: CodeGraphStore, snapshotId: number, query: string, embedder: Embedder, model: string, opts?: FindConventionsOptions): Promise<ConventionQueryResult>;
|
|
1554
|
+
|
|
1555
|
+
export { ALIAS_BASE_ATTR, ALL_ROLES, type AliasChain, type AliasChainInput, type AliasChainOptions, type AliasLoader, type AliasResolution, COMMUNITY_SUMMARY_NAMESPACE, COVERAGE_METRIC_NAME, type CachedEmbedResult, type CheckDiff, type CheckResult, type CheckRule, type CheckRulesFile, type CheckSnapshotOptions, type CheckSnapshotResult, type CheckViolation, CodeGraphStore, type ConventionArea, type ConventionCorpus, type ConventionCoverage, type ConventionMap, type ConventionMatch, type ConventionOptions, type ConventionQueryResult, type ConventionSymbol, DEAD_CODE_METRIC_NAMES, DEFAULT_CHURN_WINDOWS, DOMAIN_DDL, type DeepAst, type DeepAstInput, type DiffCheckResultsOptions, type DiffSnapshotsOptions, type EdgeKind, type EmbedAttempt, type EmbedCoverage, type EmbedSnapshotResult, type EmbeddableSymbol, type FileFingerprint, type FindConventionsOptions, type FindSimilarOptions, type ForbidImportRule, GROWTH_RISK_METRIC_NAMES, type GraphDiff, type GraphDiffSummary, type GraphEdge, type GraphFragment, type GraphMetric, type GraphNode, type HistoryMetricsOptions, INDEX_VERSION, type IdAlias, type IdAliasReason, type IndexOptions, type IndexResult, type IstanbulCoverage, KIT, LanguageExtractor, type LanguageExtractorOptions, type LayeredDepsRule, type LineSpan, type Lineage, type LineageSnapshot, type LineageStep, type LinkMethod, type LinkTestsOptions, type LoadedHistory, METRIC_CATALOGUE, MIGRATIONS, type MemberInfo, type MetricAbsence, type MetricAggregate, type MetricDelta, type MetricDescriptor, type MetricDirection, type MetricMaxRule, type MetricMinRule, type MetricProductMaxRule, type MetricRollup, type MetricSource, type MetricUnit, type NoInternalOnlyBarrelsRule, type NodeKind, type NodeRename, type NodeRole, type PackageFlag, type PackageLayer, type PackageStats, type PageRankOptions, type PageRankResult, type PageRankRow, type PairCoupling, type PairFlag, type ParamInfo, type PartitionQualityInput, type PartitionQualityResult, type PriorSnapshotOptions, type PruneOptions, type PrunePlan, type PruneResult, PythonGraphExtractor, type ReadFile, type ReferenceEdgeLite, type RelevanceOptions, type ResolveAliasOptions, type ReuseBasis, type RuleStore, type RunChecksOptions, SCHEMA_VERSION, SNAPSHOT_SCOPED_TABLES, SOURCE_METRIC_NAMES, SYMBOL_EMBEDDING_NAMESPACE, SYMBOL_ID_SEP, type Severity, type SimilarCandidate, type SimilarResult, type SnapshotInsert, type SnapshotRow, type SnapshotSpec, type SourceLanguage, type SummarizeConventionsResult, type Summarizer, type SymbolConsumers, type SymbolCouplingOptions, type SymbolCouplingPair, type SymbolSpan, type TestCoverageOwnershipOptions, type TestSourceLink, type TopMetricRow, TsMorphGraphExtractor, type TsMorphGraphExtractorOptions, type UnchangedViolation, type ValidateRulesOptions, aggregateMetrics, aliasChain, annotateRoles, attributeCoverage, buildAliases, buildConventionAreas, buildEmbedText, buildFileModuleNodes, buildIndexerMetrics, buildLineage, canonicalEdgeKind, canonicalMetricName, canonicalRole, checkSnapshot, classifyRole, collectDeclaredNames, collectDeclaredSpans, compilePatterns, computeDeadCodeMetrics, computeDeepAst, computeGrowthRiskMetrics, computeMetrics, computePageRank, computePartitionQuality, computeRecencyWindows, computeRelevance, computeRoleHints, computeSourceMetrics, computeSymbolConsumers, computeSymbolCoupling, computeTestCoverageOwnership, createAliasChain, defaultTargetCount, describeMetric, describeMetrics, detectCommunities, detectGitHead, detectGitToplevel, detectRenames, diffCheckResults, diffSnapshots, edgeWeight, embedSnapshot, embedTextsCached, externalId, fileId, findConventions, findSimilarCapability, getConventionMap, getEdgeWeight, groupTestsBySource, hashContent, hashEmbedText, indexPaths, invertBuckets, isGeneratedByHeuristic, isGeneratedFile, isInsideGitRepo, lineagePath, linkTestsToSources, listEdges, listEdgesTouching, listEmbeddableSymbols, listMetrics, listMetricsForNode, listNodes, loadCheckRules, loadGeneratedPatterns, loadHistoryMetrics, loadLineage, matchesAny, moduleId, openCodeGraph, packageId, parentModuleId, parseSymbolId, patternToRegex, planPrune, priorSnapshotForRef, pruneDanglingReferences, readSourceFiles, rebasedViolationKey, resolveAlias, resolveBarrelEdges, resolveChurnWindows, resolveGitRef, resolveSnapshot, runChecks, runPrune, snapshotPageRank, snapshotReferenceEdges, snapshotRelevance, snapshotSymbolConsumers, snapshotSymbolCoupling, snapshotViolations, structuralSignature, summarizeConventions, symbolId, testCoverageCountMetrics, tryEmbedSnapshot, validateRules, violationKey, walkSourceFiles, windowSuffix };
|
package/dist/index.js
CHANGED
|
@@ -1396,8 +1396,8 @@ function walk(requestedId, maps) {
|
|
|
1396
1396
|
}
|
|
1397
1397
|
function chainSteps(input) {
|
|
1398
1398
|
if (input.from === null) return { steps: rootPath(input.lineage, input.to, input.maxHops), connected: true };
|
|
1399
|
-
const
|
|
1400
|
-
if (
|
|
1399
|
+
const path12 = lineagePath(input.lineage, input.from, input.to, input.maxHops);
|
|
1400
|
+
if (path12) return { steps: path12, connected: true };
|
|
1401
1401
|
return { steps: [{ snapshotId: input.to, direction: "forward" }], connected: false };
|
|
1402
1402
|
}
|
|
1403
1403
|
function createAliasChain(input) {
|
|
@@ -4723,18 +4723,18 @@ function checkSnapshot(store, options) {
|
|
|
4723
4723
|
});
|
|
4724
4724
|
return { snapshot, baselineSnapshot, result };
|
|
4725
4725
|
}
|
|
4726
|
-
async function loadCheckRules(
|
|
4726
|
+
async function loadCheckRules(path12, options = {}) {
|
|
4727
4727
|
let raw;
|
|
4728
4728
|
try {
|
|
4729
|
-
raw = await readFile2(
|
|
4729
|
+
raw = await readFile2(path12, "utf8");
|
|
4730
4730
|
} catch (err) {
|
|
4731
|
-
throw new Error(`Cannot read rules file at ${
|
|
4731
|
+
throw new Error(`Cannot read rules file at ${path12}: ${errorMessage(err)}`);
|
|
4732
4732
|
}
|
|
4733
4733
|
let parsed;
|
|
4734
4734
|
try {
|
|
4735
4735
|
parsed = JSON.parse(raw);
|
|
4736
4736
|
} catch (err) {
|
|
4737
|
-
throw new Error(`Invalid JSON in ${
|
|
4737
|
+
throw new Error(`Invalid JSON in ${path12}: ${errorMessage(err)}`);
|
|
4738
4738
|
}
|
|
4739
4739
|
return validateRules(parsed, options);
|
|
4740
4740
|
}
|
|
@@ -5037,9 +5037,320 @@ function buildSymbolIndex(store, model, symbols) {
|
|
|
5037
5037
|
function toCandidate(s, score) {
|
|
5038
5038
|
return { id: s.id, name: s.name, file: s.file, signature: s.signature, purpose: s.purpose, score };
|
|
5039
5039
|
}
|
|
5040
|
+
|
|
5041
|
+
// src/conventions/communities.ts
|
|
5042
|
+
function detectCommunities(fileIds, edges, opts) {
|
|
5043
|
+
const nodeSet = new Set(fileIds);
|
|
5044
|
+
const adj = buildUndirectedAdjacency(edges, nodeSet);
|
|
5045
|
+
const communityOf = greedyModularity(fileIds, adj, opts);
|
|
5046
|
+
const out = /* @__PURE__ */ new Map();
|
|
5047
|
+
for (const f of fileIds) pushMulti(out, communityOf.get(f) ?? f, f);
|
|
5048
|
+
return out;
|
|
5049
|
+
}
|
|
5050
|
+
function buildUndirectedAdjacency(edges, nodeSet) {
|
|
5051
|
+
const adj = /* @__PURE__ */ new Map();
|
|
5052
|
+
const link = (a, b) => {
|
|
5053
|
+
let row = adj.get(a);
|
|
5054
|
+
if (!row) {
|
|
5055
|
+
row = /* @__PURE__ */ new Map();
|
|
5056
|
+
adj.set(a, row);
|
|
5057
|
+
}
|
|
5058
|
+
row.set(b, (row.get(b) ?? 0) + 1);
|
|
5059
|
+
};
|
|
5060
|
+
for (const e of edges) {
|
|
5061
|
+
if (e.srcId === e.dstId) continue;
|
|
5062
|
+
if (!nodeSet.has(e.srcId) || !nodeSet.has(e.dstId)) continue;
|
|
5063
|
+
link(e.srcId, e.dstId);
|
|
5064
|
+
link(e.dstId, e.srcId);
|
|
5065
|
+
}
|
|
5066
|
+
return adj;
|
|
5067
|
+
}
|
|
5068
|
+
function greedyModularity(nodes, adj, opts) {
|
|
5069
|
+
const state = initCommunities(nodes, adj);
|
|
5070
|
+
if (state.twoM === 0) return new Map(nodes.map((n) => [n, n]));
|
|
5071
|
+
for (; ; ) {
|
|
5072
|
+
const coarsen = opts?.targetCount !== void 0 && state.members.size > opts.targetCount;
|
|
5073
|
+
const best = bestMerge(state, coarsen, opts?.maxSize);
|
|
5074
|
+
if (!best) break;
|
|
5075
|
+
applyMerge(state, best.i, best.j);
|
|
5076
|
+
}
|
|
5077
|
+
return communityLabels(state);
|
|
5078
|
+
}
|
|
5079
|
+
function initCommunities(nodes, adj) {
|
|
5080
|
+
const members = /* @__PURE__ */ new Map();
|
|
5081
|
+
const deg = /* @__PURE__ */ new Map();
|
|
5082
|
+
const between = /* @__PURE__ */ new Map();
|
|
5083
|
+
let twoM = 0;
|
|
5084
|
+
for (const n of nodes) {
|
|
5085
|
+
const row = /* @__PURE__ */ new Map();
|
|
5086
|
+
let d = 0;
|
|
5087
|
+
for (const [k, w] of adj.get(n) ?? []) {
|
|
5088
|
+
row.set(k, w);
|
|
5089
|
+
d += w;
|
|
5090
|
+
}
|
|
5091
|
+
members.set(n, [n]);
|
|
5092
|
+
deg.set(n, d);
|
|
5093
|
+
between.set(n, row);
|
|
5094
|
+
twoM += d;
|
|
5095
|
+
}
|
|
5096
|
+
return { members, deg, between, twoM };
|
|
5097
|
+
}
|
|
5098
|
+
function bestMerge(state, allowNegative, maxSize) {
|
|
5099
|
+
const { members, between, deg, twoM } = state;
|
|
5100
|
+
let best = null;
|
|
5101
|
+
for (const [i, row] of between) {
|
|
5102
|
+
for (const [j, eij] of row) {
|
|
5103
|
+
if (i >= j) continue;
|
|
5104
|
+
if (maxSize !== void 0 && members.get(i).length + members.get(j).length > maxSize) {
|
|
5105
|
+
continue;
|
|
5106
|
+
}
|
|
5107
|
+
const dQ = 2 * eij / twoM - 2 * deg.get(i) * deg.get(j) / (twoM * twoM);
|
|
5108
|
+
if (!allowNegative && dQ <= 1e-12) continue;
|
|
5109
|
+
if (best === null || dQ > best.dQ) best = { i, j, dQ };
|
|
5110
|
+
}
|
|
5111
|
+
}
|
|
5112
|
+
return best;
|
|
5113
|
+
}
|
|
5114
|
+
function applyMerge(state, i, j) {
|
|
5115
|
+
const { members, deg, between } = state;
|
|
5116
|
+
members.get(i).push(...members.get(j));
|
|
5117
|
+
members.delete(j);
|
|
5118
|
+
deg.set(i, deg.get(i) + deg.get(j));
|
|
5119
|
+
deg.delete(j);
|
|
5120
|
+
const rowI = between.get(i);
|
|
5121
|
+
for (const [k, w] of between.get(j)) {
|
|
5122
|
+
if (k === i) continue;
|
|
5123
|
+
rowI.set(k, (rowI.get(k) ?? 0) + w);
|
|
5124
|
+
const rowK = between.get(k);
|
|
5125
|
+
rowK.set(i, (rowK.get(i) ?? 0) + w);
|
|
5126
|
+
rowK.delete(j);
|
|
5127
|
+
}
|
|
5128
|
+
rowI.delete(j);
|
|
5129
|
+
between.delete(j);
|
|
5130
|
+
}
|
|
5131
|
+
function communityLabels(state) {
|
|
5132
|
+
const out = /* @__PURE__ */ new Map();
|
|
5133
|
+
const reps = [...state.members.keys()].sort();
|
|
5134
|
+
reps.forEach((rep, idx) => {
|
|
5135
|
+
for (const n of state.members.get(rep)) out.set(n, `c${idx}`);
|
|
5136
|
+
});
|
|
5137
|
+
return out;
|
|
5138
|
+
}
|
|
5139
|
+
function pushMulti(map, key, value) {
|
|
5140
|
+
let list = map.get(key);
|
|
5141
|
+
if (!list) {
|
|
5142
|
+
list = [];
|
|
5143
|
+
map.set(key, list);
|
|
5144
|
+
}
|
|
5145
|
+
list.push(value);
|
|
5146
|
+
}
|
|
5147
|
+
|
|
5148
|
+
// src/conventions/areas.ts
|
|
5149
|
+
import * as path11 from "path";
|
|
5150
|
+
import { contentHashOf as contentHashOf2 } from "@titan-design/store-sqlite";
|
|
5151
|
+
var DEFAULT_MIN_SIZE = 3;
|
|
5152
|
+
var PROMPT_FILE_CAP = 30;
|
|
5153
|
+
var PROMPT_SYMBOL_CAP = 15;
|
|
5154
|
+
function defaultTargetCount(fileCount) {
|
|
5155
|
+
return Math.min(40, Math.max(6, Math.ceil(fileCount / 25)));
|
|
5156
|
+
}
|
|
5157
|
+
function buildConventionAreas(store, snapshotId, opts) {
|
|
5158
|
+
const nodes = store.listNodes(snapshotId, { includeSymbols: true });
|
|
5159
|
+
const { files, edges } = resolvedFileGraph(store, snapshotId, nodes);
|
|
5160
|
+
const symbolsByFile = collectExportedSymbols(nodes);
|
|
5161
|
+
const areas = [];
|
|
5162
|
+
const prompts = /* @__PURE__ */ new Map();
|
|
5163
|
+
for (const members of partitionFiles(files, edges, opts)) {
|
|
5164
|
+
const { area, prompt } = buildArea(members, edges, symbolsByFile);
|
|
5165
|
+
areas.push(area);
|
|
5166
|
+
prompts.set(area.contentHash, prompt);
|
|
5167
|
+
}
|
|
5168
|
+
const grouped = areas.reduce((s, a) => s + a.size, 0);
|
|
5169
|
+
return { areas, prompts, coverage: { files: files.length, grouped, areas: areas.length, summarized: 0 } };
|
|
5170
|
+
}
|
|
5171
|
+
function resolvedFileGraph(store, snapshotId, nodes) {
|
|
5172
|
+
const files = nodes.filter((n) => n.kind === "file" && n.role !== "generated").map((n) => n.id).sort();
|
|
5173
|
+
const fileSet = new Set(files);
|
|
5174
|
+
const edges = resolveBarrelEdges(nodes, store.listEdges(snapshotId)).filter(
|
|
5175
|
+
(e) => e.srcId !== e.dstId && fileSet.has(e.srcId) && fileSet.has(e.dstId)
|
|
5176
|
+
);
|
|
5177
|
+
return { files, edges };
|
|
5178
|
+
}
|
|
5179
|
+
function partitionFiles(files, edges, opts) {
|
|
5180
|
+
const target = opts?.targetCount ?? defaultTargetCount(files.length);
|
|
5181
|
+
const minSize = opts?.minSize ?? DEFAULT_MIN_SIZE;
|
|
5182
|
+
const maxSize = Math.max(minSize * 2, Math.ceil(files.length / target * 2));
|
|
5183
|
+
return [...detectCommunities(files, edges, { targetCount: target, maxSize }).values()].filter((members) => members.length >= minSize).sort(bySizeThenFirstFile);
|
|
5184
|
+
}
|
|
5185
|
+
function bySizeThenFirstFile(a, b) {
|
|
5186
|
+
if (a.length !== b.length) return b.length - a.length;
|
|
5187
|
+
return (a[0] ?? "") < (b[0] ?? "") ? -1 : 1;
|
|
5188
|
+
}
|
|
5189
|
+
function collectExportedSymbols(nodes) {
|
|
5190
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
5191
|
+
for (const n of nodes) {
|
|
5192
|
+
if (n.kind !== "symbol") continue;
|
|
5193
|
+
const attrs = n.attrs ?? {};
|
|
5194
|
+
if (attrs.exported !== true || !attrs.signature) continue;
|
|
5195
|
+
const file = n.parentId ?? n.id.split("#")[0] ?? n.id;
|
|
5196
|
+
const list = byFile.get(file) ?? [];
|
|
5197
|
+
list.push({ name: n.name, signature: attrs.signature, purpose: attrs.purpose });
|
|
5198
|
+
byFile.set(file, list);
|
|
5199
|
+
}
|
|
5200
|
+
return byFile;
|
|
5201
|
+
}
|
|
5202
|
+
function buildArea(members, edges, symbolsByFile) {
|
|
5203
|
+
const files = rankByDegree(members, edges);
|
|
5204
|
+
const label = dominantDirectory(files);
|
|
5205
|
+
const topSymbols = pickTopSymbols(files, symbolsByFile);
|
|
5206
|
+
const prompt = buildPrompt(label, files, topSymbols);
|
|
5207
|
+
const area = {
|
|
5208
|
+
id: `area-${contentHashOf2(files.join("\n")).slice(0, 8)}`,
|
|
5209
|
+
label,
|
|
5210
|
+
files,
|
|
5211
|
+
size: files.length,
|
|
5212
|
+
topSymbols,
|
|
5213
|
+
contentHash: contentHashOf2(prompt)
|
|
5214
|
+
};
|
|
5215
|
+
return { area, prompt };
|
|
5216
|
+
}
|
|
5217
|
+
function rankByDegree(members, edges) {
|
|
5218
|
+
const memberSet = new Set(members);
|
|
5219
|
+
const degree = /* @__PURE__ */ new Map();
|
|
5220
|
+
for (const e of edges) {
|
|
5221
|
+
if (!memberSet.has(e.srcId) || !memberSet.has(e.dstId)) continue;
|
|
5222
|
+
degree.set(e.srcId, (degree.get(e.srcId) ?? 0) + 1);
|
|
5223
|
+
degree.set(e.dstId, (degree.get(e.dstId) ?? 0) + 1);
|
|
5224
|
+
}
|
|
5225
|
+
return [...members].sort((a, b) => {
|
|
5226
|
+
const d = (degree.get(b) ?? 0) - (degree.get(a) ?? 0);
|
|
5227
|
+
return d !== 0 ? d : a < b ? -1 : 1;
|
|
5228
|
+
});
|
|
5229
|
+
}
|
|
5230
|
+
function dominantDirectory(files) {
|
|
5231
|
+
const counts = /* @__PURE__ */ new Map();
|
|
5232
|
+
for (const f of files) {
|
|
5233
|
+
const dir = path11.posix.dirname(f);
|
|
5234
|
+
counts.set(dir, (counts.get(dir) ?? 0) + 1);
|
|
5235
|
+
}
|
|
5236
|
+
const dirs = [...counts.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
|
|
5237
|
+
const topDir = dirs[0]?.[0] ?? ".";
|
|
5238
|
+
return dirs.length > 1 ? `${topDir} (+${dirs.length - 1} dirs)` : topDir;
|
|
5239
|
+
}
|
|
5240
|
+
function pickTopSymbols(rankedFiles, symbolsByFile) {
|
|
5241
|
+
const out = [];
|
|
5242
|
+
for (const file of rankedFiles) {
|
|
5243
|
+
for (const sym of symbolsByFile.get(file) ?? []) {
|
|
5244
|
+
out.push(sym);
|
|
5245
|
+
if (out.length >= PROMPT_SYMBOL_CAP) return out;
|
|
5246
|
+
}
|
|
5247
|
+
}
|
|
5248
|
+
return out;
|
|
5249
|
+
}
|
|
5250
|
+
function buildPrompt(label, files, symbols) {
|
|
5251
|
+
const shown = files.slice(0, PROMPT_FILE_CAP);
|
|
5252
|
+
const lines = [
|
|
5253
|
+
'You are writing one entry of a repository "convention map" \u2014 a',
|
|
5254
|
+
"capability-altitude summary coding agents read at PLAN time to learn how",
|
|
5255
|
+
"this repo does things and where new code belongs.",
|
|
5256
|
+
"",
|
|
5257
|
+
`Capability area: ${label}`,
|
|
5258
|
+
`Files (${files.length}):`,
|
|
5259
|
+
...shown.map((f) => `- ${f}`)
|
|
5260
|
+
];
|
|
5261
|
+
if (files.length > shown.length) lines.push(`- \u2026and ${files.length - shown.length} more`);
|
|
5262
|
+
if (symbols.length > 0) {
|
|
5263
|
+
lines.push("Key exported symbols:");
|
|
5264
|
+
for (const s of symbols) lines.push(`- ${s.signature}${s.purpose ? ` \u2014 ${s.purpose}` : ""}`);
|
|
5265
|
+
}
|
|
5266
|
+
lines.push(
|
|
5267
|
+
"",
|
|
5268
|
+
"Write 2-3 sentences: (1) what this area does, and (2) HOW \u2014 the key entry",
|
|
5269
|
+
"points, patterns and conventions to follow when adding related code.",
|
|
5270
|
+
"Plain text only, no preamble or headings."
|
|
5271
|
+
);
|
|
5272
|
+
return lines.join("\n");
|
|
5273
|
+
}
|
|
5274
|
+
|
|
5275
|
+
// src/conventions/summaries.ts
|
|
5276
|
+
import { CacheBlobTable as CacheBlobTable2 } from "@titan-design/store-sqlite";
|
|
5277
|
+
var COMMUNITY_SUMMARY_NAMESPACE = "code-graph/community-summary";
|
|
5278
|
+
function summaryCache(store) {
|
|
5279
|
+
return new CacheBlobTable2(store.db, { name: KIT.cacheBlob });
|
|
5280
|
+
}
|
|
5281
|
+
function loadSummaries(store, model, hashes) {
|
|
5282
|
+
const cache = summaryCache(store);
|
|
5283
|
+
const found = /* @__PURE__ */ new Map();
|
|
5284
|
+
for (const contentHash of new Set(hashes)) {
|
|
5285
|
+
const hit = cache.get({ namespace: COMMUNITY_SUMMARY_NAMESPACE, model, contentHash });
|
|
5286
|
+
if (hit) found.set(contentHash, hit.value.toString("utf8"));
|
|
5287
|
+
}
|
|
5288
|
+
return found;
|
|
5289
|
+
}
|
|
5290
|
+
function storeSummary(store, model, contentHash, summary) {
|
|
5291
|
+
summaryCache(store).put({ namespace: COMMUNITY_SUMMARY_NAMESPACE, model, contentHash }, summary);
|
|
5292
|
+
}
|
|
5293
|
+
function attachSummaries(areas, summaries) {
|
|
5294
|
+
for (const area of areas) area.summary = summaries.get(area.contentHash);
|
|
5295
|
+
}
|
|
5296
|
+
async function summarizeConventions(store, snapshotId, summarizer, opts) {
|
|
5297
|
+
const { areas, prompts, coverage } = buildConventionAreas(store, snapshotId, opts);
|
|
5298
|
+
attachSummaries(areas, loadSummaries(store, summarizer.model, areas.map((a) => a.contentHash)));
|
|
5299
|
+
let newlySummarized = 0;
|
|
5300
|
+
for (const area of areas) {
|
|
5301
|
+
if (area.summary !== void 0) continue;
|
|
5302
|
+
const summary = (await summarizer.summarize(prompts.get(area.contentHash))).trim();
|
|
5303
|
+
storeSummary(store, summarizer.model, area.contentHash, summary);
|
|
5304
|
+
area.summary = summary;
|
|
5305
|
+
newlySummarized++;
|
|
5306
|
+
}
|
|
5307
|
+
coverage.summarized = areas.filter((a) => a.summary).length;
|
|
5308
|
+
return { model: summarizer.model, coverage, areas, newlySummarized, reused: areas.length - newlySummarized };
|
|
5309
|
+
}
|
|
5310
|
+
function getConventionMap(store, snapshotId, model, opts) {
|
|
5311
|
+
const { areas, coverage } = buildConventionAreas(store, snapshotId, opts);
|
|
5312
|
+
attachSummaries(areas, loadSummaries(store, model, areas.map((a) => a.contentHash)));
|
|
5313
|
+
coverage.summarized = areas.filter((a) => a.summary).length;
|
|
5314
|
+
return { model, coverage, areas };
|
|
5315
|
+
}
|
|
5316
|
+
|
|
5317
|
+
// src/conventions/query.ts
|
|
5318
|
+
import { BruteForceVectorIndex as BruteForceVectorIndex2, vectorRetriever as vectorRetriever2 } from "@titan-design/retrieval";
|
|
5319
|
+
var MATCH_FILE_CAP = 5;
|
|
5320
|
+
var DEFAULT_LIMIT = 3;
|
|
5321
|
+
async function findConventions(store, snapshotId, query, embedder, model, opts = {}) {
|
|
5322
|
+
const map = getConventionMap(store, snapshotId, model, opts);
|
|
5323
|
+
const summarized = map.areas.filter((a) => Boolean(a.summary));
|
|
5324
|
+
if (summarized.length === 0) {
|
|
5325
|
+
throw new Error(`No convention summaries stored for model ${model}; run summarizeConventions first.`);
|
|
5326
|
+
}
|
|
5327
|
+
const { byHash } = await embedTextsCached(store, embedder, summarized.map((a) => a.summary));
|
|
5328
|
+
const index = new BruteForceVectorIndex2();
|
|
5329
|
+
for (const area of summarized) index.add(area.id, byHash.get(hashEmbedText(area.summary)));
|
|
5330
|
+
const hits = await vectorRetriever2(embedder, index).retrieve(query, { limit: opts.limit ?? DEFAULT_LIMIT });
|
|
5331
|
+
const byId = new Map(summarized.map((a) => [a.id, a]));
|
|
5332
|
+
return {
|
|
5333
|
+
query,
|
|
5334
|
+
model,
|
|
5335
|
+
embeddingModel: embedder.model,
|
|
5336
|
+
coverage: map.coverage,
|
|
5337
|
+
matches: hits.map((h) => toMatch(byId.get(h.id), h.score ?? 0))
|
|
5338
|
+
};
|
|
5339
|
+
}
|
|
5340
|
+
function toMatch(area, score) {
|
|
5341
|
+
return {
|
|
5342
|
+
id: area.id,
|
|
5343
|
+
label: area.label,
|
|
5344
|
+
summary: area.summary,
|
|
5345
|
+
files: area.files.slice(0, MATCH_FILE_CAP),
|
|
5346
|
+
size: area.size,
|
|
5347
|
+
score
|
|
5348
|
+
};
|
|
5349
|
+
}
|
|
5040
5350
|
export {
|
|
5041
5351
|
ALIAS_BASE_ATTR,
|
|
5042
5352
|
ALL_ROLES,
|
|
5353
|
+
COMMUNITY_SUMMARY_NAMESPACE,
|
|
5043
5354
|
COVERAGE_METRIC_NAME,
|
|
5044
5355
|
CodeGraphStore,
|
|
5045
5356
|
DEAD_CODE_METRIC_NAMES,
|
|
@@ -5063,6 +5374,7 @@ export {
|
|
|
5063
5374
|
annotateRoles,
|
|
5064
5375
|
attributeCoverage,
|
|
5065
5376
|
buildAliases,
|
|
5377
|
+
buildConventionAreas,
|
|
5066
5378
|
buildEmbedText,
|
|
5067
5379
|
buildFileModuleNodes,
|
|
5068
5380
|
buildIndexerMetrics,
|
|
@@ -5089,8 +5401,10 @@ export {
|
|
|
5089
5401
|
computeSymbolCoupling,
|
|
5090
5402
|
computeTestCoverageOwnership,
|
|
5091
5403
|
createAliasChain,
|
|
5404
|
+
defaultTargetCount,
|
|
5092
5405
|
describeMetric,
|
|
5093
5406
|
describeMetrics,
|
|
5407
|
+
detectCommunities,
|
|
5094
5408
|
detectGitHead,
|
|
5095
5409
|
detectGitToplevel,
|
|
5096
5410
|
detectRenames,
|
|
@@ -5101,7 +5415,9 @@ export {
|
|
|
5101
5415
|
embedTextsCached,
|
|
5102
5416
|
externalId,
|
|
5103
5417
|
fileId,
|
|
5418
|
+
findConventions,
|
|
5104
5419
|
findSimilarCapability,
|
|
5420
|
+
getConventionMap,
|
|
5105
5421
|
getEdgeWeight,
|
|
5106
5422
|
getLanguageFromPath3 as getLanguageFromPath,
|
|
5107
5423
|
getSupportedLanguages,
|
|
@@ -5153,6 +5469,7 @@ export {
|
|
|
5153
5469
|
snapshotSymbolCoupling,
|
|
5154
5470
|
snapshotViolations,
|
|
5155
5471
|
structuralSignature,
|
|
5472
|
+
summarizeConventions,
|
|
5156
5473
|
symbolId,
|
|
5157
5474
|
testCoverageCountMetrics,
|
|
5158
5475
|
tryEmbedSnapshot,
|