@davesheffer/hunch 1.39.2 → 1.40.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/dist/cli/index.js +154 -64
- package/dist/cli/serve.d.ts +14 -0
- package/dist/cli/serve.js +18 -4
- package/dist/constitution/g2BehaviorCandidates.js +6 -1
- package/dist/constitution/g2Candidates.js +11 -1
- package/dist/constitution/structural.js +10 -0
- package/dist/core/correction.js +12 -7
- package/dist/core/delivery.d.ts +34 -0
- package/dist/core/delivery.js +60 -1
- package/dist/core/docanchors.js +195 -11
- package/dist/core/format.js +6 -1
- package/dist/core/glob.d.ts +25 -0
- package/dist/core/glob.js +43 -0
- package/dist/core/hookcache.d.ts +9 -2
- package/dist/core/hookcache.js +10 -3
- package/dist/core/paths.d.ts +48 -5
- package/dist/core/paths.js +89 -6
- package/dist/core/procstart.d.ts +94 -0
- package/dist/core/procstart.js +244 -0
- package/dist/core/served.d.ts +6 -3
- package/dist/core/served.js +10 -6
- package/dist/core/taskDelivery.js +27 -1
- package/dist/core/taskReport.d.ts +2 -0
- package/dist/core/taskReport.js +46 -11
- package/dist/core/taskReportEvidence.js +17 -1
- package/dist/core/taskReportHook.d.ts +19 -0
- package/dist/core/taskReportHook.js +54 -6
- package/dist/core/verifyLauncher.d.ts +21 -0
- package/dist/core/verifyLauncher.js +37 -0
- package/dist/core/workspace.d.ts +9 -0
- package/dist/core/workspace.js +15 -0
- package/dist/extractors/git.d.ts +5 -0
- package/dist/extractors/git.js +87 -10
- package/dist/extractors/indexer.d.ts +10 -0
- package/dist/extractors/indexer.js +56 -10
- package/dist/extractors/k8sManifest.d.ts +13 -0
- package/dist/extractors/k8sManifest.js +207 -23
- package/dist/extractors/landscapeDiscovery.js +9 -1
- package/dist/extractors/nativeTreeSitter.d.ts +24 -0
- package/dist/extractors/nativeTreeSitter.js +54 -1
- package/dist/extractors/parse.js +6 -1
- package/dist/integrations/claudemd.js +3 -3
- package/dist/integrations/hookmatch.d.ts +25 -0
- package/dist/integrations/hookmatch.js +58 -0
- package/dist/integrations/hooks.d.ts +79 -9
- package/dist/integrations/hooks.js +559 -46
- package/dist/integrations/providers.js +2 -17
- package/dist/integrations/scaffold.d.ts +1 -1
- package/dist/integrations/scaffold.js +24 -21
- package/dist/integrations/workspaceLedger.d.ts +10 -0
- package/dist/integrations/workspaceLedger.js +9 -1
- package/dist/mcp/server.d.ts +10 -0
- package/dist/mcp/server.js +32 -16
- package/dist/mcp/taskReportTools.d.ts +5 -9
- package/dist/mcp/taskReportTools.js +9 -20
- package/dist/serve/writelock.d.ts +21 -1
- package/dist/serve/writelock.js +157 -16
- package/dist/store/changeLedger.d.ts +9 -3
- package/dist/store/changeLedger.js +36 -10
- package/dist/store/hunchStore.d.ts +42 -2
- package/dist/store/hunchStore.js +105 -17
- package/dist/store/jsonStore.d.ts +6 -4
- package/dist/store/jsonStore.js +225 -32
- package/dist/store/merge.d.ts +7 -4
- package/dist/store/merge.js +65 -5
- package/dist/store/privateMigrate.js +10 -0
- package/dist/wiki/adopt.js +13 -2
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -128,9 +128,15 @@ export declare function compactLedger(hunchDir: string, scope: Scope, opts?: {
|
|
|
128
128
|
/** Three-way merge of one scope's ledger, for the git merge driver: two clones that both
|
|
129
129
|
* appended to the same partition. The union of events is kept (identity = what changed, to
|
|
130
130
|
* which hash, when, by whom), ordered by time then ours-before-theirs, and RE-SEQUENCED from
|
|
131
|
-
* the higher floor
|
|
132
|
-
*
|
|
133
|
-
*
|
|
131
|
+
* the higher floor. When that renumbering moves an event either side had already published,
|
|
132
|
+
* no pre-merge cursor of EITHER side can be trusted — a cursor could otherwise sit above an
|
|
133
|
+
* event that now holds a lower seq and never see it (issue #285). The floor is then raised
|
|
134
|
+
* past BOTH pre-merge heads, so every such cursor satisfies subscribe's `after_seq <
|
|
135
|
+
* floor_seq` and resynchronizes from the floor; a cursor equal to the larger head is exactly
|
|
136
|
+
* the failing case, hence the `+ 1`. A merge that renumbers nothing keeps today's seqs (no
|
|
137
|
+
* cursor could have seen a different numbering) and the floor stays put. Idempotency entries
|
|
138
|
+
* are unioned; a key both sides used for different records is a conflict the caller must
|
|
139
|
+
* surface (ours is kept). */
|
|
134
140
|
export declare function mergeLedgers(base: Ledger | null, ours: Ledger, theirs: Ledger): {
|
|
135
141
|
ledger: Ledger;
|
|
136
142
|
conflicts: string[];
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* other record, appended atomically (con_902759b3dc). It holds the strictly ordered
|
|
6
6
|
* ChangeEvent stream for that scope (seq 1, 2, 3 … with no gaps) plus the idempotency
|
|
7
7
|
* table the write verb replays from. Seq is per scope, assigned by the writer in the
|
|
8
|
-
* home the scope lives in; a scope has exactly ONE ledger, so
|
|
9
|
-
* sequence to reconcile.
|
|
10
|
-
*
|
|
8
|
+
* home the scope lives in; a scope has exactly ONE ledger per clone, so within a clone there
|
|
9
|
+
* is never a second sequence to reconcile. Two clones that both appended to one scope are
|
|
10
|
+
* reconciled by `mergeLedgers` (below), for the git merge driver.
|
|
11
11
|
*/
|
|
12
12
|
import { mkdirSync } from "node:fs";
|
|
13
13
|
import { basename, join, resolve } from "node:path";
|
|
@@ -34,8 +34,9 @@ export const LedgerSchema = z.object({
|
|
|
34
34
|
schema: z.literal(LEDGER_SCHEMA_VERSION),
|
|
35
35
|
scope: ScopeSchema,
|
|
36
36
|
head_seq: z.number().int().nonnegative(),
|
|
37
|
-
/** Events below this seq were compacted away
|
|
38
|
-
*
|
|
37
|
+
/** Events below this seq were compacted away, or were invalidated by a merge that renumbered
|
|
38
|
+
* them (see `mergeLedgers`). `events` starts at floor_seq + 1. A subscriber whose cursor is
|
|
39
|
+
* below the floor must resynchronize (the contract's gap rule, made explicit). */
|
|
39
40
|
floor_seq: z.number().int().nonnegative().default(0),
|
|
40
41
|
events: z.array(ChangeEventSchema),
|
|
41
42
|
idempotency: z.record(z.string(), IdempotencyEntrySchema).default({}),
|
|
@@ -151,9 +152,15 @@ const eventIdentity = (e) => [e.change, e.facet, e.record_id, e.record_hash, e.a
|
|
|
151
152
|
/** Three-way merge of one scope's ledger, for the git merge driver: two clones that both
|
|
152
153
|
* appended to the same partition. The union of events is kept (identity = what changed, to
|
|
153
154
|
* which hash, when, by whom), ordered by time then ours-before-theirs, and RE-SEQUENCED from
|
|
154
|
-
* the higher floor
|
|
155
|
-
*
|
|
156
|
-
*
|
|
155
|
+
* the higher floor. When that renumbering moves an event either side had already published,
|
|
156
|
+
* no pre-merge cursor of EITHER side can be trusted — a cursor could otherwise sit above an
|
|
157
|
+
* event that now holds a lower seq and never see it (issue #285). The floor is then raised
|
|
158
|
+
* past BOTH pre-merge heads, so every such cursor satisfies subscribe's `after_seq <
|
|
159
|
+
* floor_seq` and resynchronizes from the floor; a cursor equal to the larger head is exactly
|
|
160
|
+
* the failing case, hence the `+ 1`. A merge that renumbers nothing keeps today's seqs (no
|
|
161
|
+
* cursor could have seen a different numbering) and the floor stays put. Idempotency entries
|
|
162
|
+
* are unioned; a key both sides used for different records is a conflict the caller must
|
|
163
|
+
* surface (ours is kept). */
|
|
157
164
|
export function mergeLedgers(base, ours, theirs) {
|
|
158
165
|
if (scopePath(ours.scope) !== scopePath(theirs.scope))
|
|
159
166
|
throw new Error("ledgers for different scopes cannot be merged");
|
|
@@ -169,9 +176,28 @@ export function mergeLedgers(base, ours, theirs) {
|
|
|
169
176
|
add(e);
|
|
170
177
|
for (const e of theirs.events)
|
|
171
178
|
add(e);
|
|
172
|
-
|
|
179
|
+
// The tiebreak is "ours before theirs", so only an event theirs ALONE contributed sorts late:
|
|
180
|
+
// side 1 iff theirs holds it and neither ours nor base does. Deciding by object identity
|
|
181
|
+
// (`includes`) would instead put anything ours compacted away — base's object, which `add`
|
|
182
|
+
// keeps — after its same-`at` siblings and silently reorder a batch both sides already had.
|
|
183
|
+
const ourIdentities = new Set(ours.events.map(eventIdentity));
|
|
184
|
+
const baseIdentities = new Set((base?.events ?? []).map(eventIdentity));
|
|
185
|
+
const theirsOnly = (e) => {
|
|
186
|
+
const k = eventIdentity(e);
|
|
187
|
+
return !ourIdentities.has(k) && !baseIdentities.has(k);
|
|
188
|
+
};
|
|
189
|
+
const ranked = order.map((e, i) => ({ e, i, side: (theirsOnly(e) ? 1 : 0) }));
|
|
173
190
|
ranked.sort((a, b) => a.e.at.localeCompare(b.e.at) || a.side - b.side || a.i - b.i);
|
|
174
|
-
const
|
|
191
|
+
const theirSeqs = new Map(theirs.events.map((e) => [eventIdentity(e), e.seq]));
|
|
192
|
+
const ourSeqs = new Map(ours.events.map((e) => [eventIdentity(e), e.seq]));
|
|
193
|
+
let floor = Math.max(base?.floor_seq ?? 0, ours.floor_seq, theirs.floor_seq);
|
|
194
|
+
const renumbers = ranked.some(({ e }, i) => {
|
|
195
|
+
const k = eventIdentity(e);
|
|
196
|
+
const merged = floor + i + 1;
|
|
197
|
+
return (ourSeqs.get(k) ?? merged) !== merged || (theirSeqs.get(k) ?? merged) !== merged;
|
|
198
|
+
});
|
|
199
|
+
if (renumbers)
|
|
200
|
+
floor = Math.max(floor, Math.max(ours.head_seq, theirs.head_seq) + 1);
|
|
175
201
|
const events = ranked.map(({ e }, i) => ({ ...e, seq: floor + i + 1 }));
|
|
176
202
|
const conflicts = [];
|
|
177
203
|
const idempotency = { ...(base?.idempotency ?? {}), ...theirs.idempotency, ...ours.idempotency };
|
|
@@ -335,6 +335,29 @@ export declare class HunchStore {
|
|
|
335
335
|
* present in the indexed symbol/component tables can enter the frontier, so
|
|
336
336
|
* shared external-package hubs never become context or bridge unrelated symbols. */
|
|
337
337
|
private graphExpand;
|
|
338
|
+
/** The WIDE definition of "is `t` a real path?" — the gate used by the matchers
|
|
339
|
+
* that ATTRIBUTE records to a target silently (why(), liveFindingsFor, tasksFor,
|
|
340
|
+
* `resolveSymbols` in the MCP server). They also run for a file an agent is
|
|
341
|
+
* about to create, where the covering component glob is the only signal there
|
|
342
|
+
* is, so the answer must not depend on the file already being on disk. The
|
|
343
|
+
* resolvers that NAME the file they resolved to (resolveNodeIds, structure())
|
|
344
|
+
* deliberately use the narrower `isRepoFile` instead.
|
|
345
|
+
*
|
|
346
|
+
* Graph data FIRST (a symbol's exact file, or an indexed component's path glob),
|
|
347
|
+
* so a deleted-but-still-indexed path and a time-travel (`asOf`) query answer the
|
|
348
|
+
* same as before (issue #299). The working tree is consulted only as a LAST
|
|
349
|
+
* resort, for the case the index cannot see: a real file with zero tree-sitter
|
|
350
|
+
* symbols and no covering component glob, which otherwise suffix-leaked an
|
|
351
|
+
* unrelated same-basename file's records (issue #334). When the working tree is
|
|
352
|
+
* unavailable or the file is gone, behaviour is exactly the pre-#334 behaviour.
|
|
353
|
+
*
|
|
354
|
+
* `t` must already be repo-relative (`repoRelativeTarget`). `graph` lets a caller
|
|
355
|
+
* that has already narrowed the graph (why()'s `canRead` visibility filter) ask
|
|
356
|
+
* the question over exactly the records it may see, rather than all of them. */
|
|
357
|
+
isKnownPath(t: string, graph?: {
|
|
358
|
+
symbols: readonly Symbol[];
|
|
359
|
+
components: readonly Component[];
|
|
360
|
+
}): boolean;
|
|
338
361
|
/** All decisions/bugs/constraints/symbols/components touching a file path or
|
|
339
362
|
* symbol name (hunch_why). Pass `{ asOf }` (an ISO instant) to TIME-TRAVEL:
|
|
340
363
|
* return only decisions/constraints whose valid-time window contained that
|
|
@@ -379,7 +402,20 @@ export declare class HunchStore {
|
|
|
379
402
|
* or the id itself when unindexed. */
|
|
380
403
|
nodeLabel(id: string): string;
|
|
381
404
|
/** Resolve a free-form target (symbol id / name / file path, component id / name)
|
|
382
|
-
* to graph node ids — symbols win over components, exact file before suffix.
|
|
405
|
+
* to graph node ids — symbols win over components, exact file before suffix.
|
|
406
|
+
* Shares why()/resolveSymbols' tiered matcher rather than one un-tiered SQL OR:
|
|
407
|
+
* the flat query matched every tier at once, so a root `index.ts` also returned
|
|
408
|
+
* `a/index.ts`'s symbols, and an absolute target matched nothing at all (#335).
|
|
409
|
+
*
|
|
410
|
+
* The suffix gate here is `isRepoFile`, NOT the wider `isKnownPath`: the
|
|
411
|
+
* exact-file tier has already run, so the only case left is a real on-disk file
|
|
412
|
+
* with zero symbols (#334). Glob coverage alone would wrongly block a target
|
|
413
|
+
* that no file actually carries — with a `src/**` component, `src/x.ts` living
|
|
414
|
+
* at `packages/a/src/x.ts` stopped resolving. This resolver names the file it
|
|
415
|
+
* resolved to in its result, so a suffix resolution is visible and only a file
|
|
416
|
+
* that really exists may block it; why()/the pre-edit hook attribute records
|
|
417
|
+
* silently and also run for a file about to be created, so they keep the wider
|
|
418
|
+
* `isKnownPath`. */
|
|
383
419
|
resolveNodeIds(target: string): string[];
|
|
384
420
|
/** PR impact (read-only, ADVISORY — never gates): the dependency + memory surface
|
|
385
421
|
* of a change. Composes the SAME primitives as buildCheckReport (blast radius,
|
|
@@ -590,9 +626,13 @@ export type StructureView = {
|
|
|
590
626
|
callers: string[];
|
|
591
627
|
callees: string[];
|
|
592
628
|
}>;
|
|
593
|
-
}
|
|
629
|
+
}
|
|
630
|
+
/** `realFile`: the target IS a regular file in the working tree, it just has no
|
|
631
|
+
* indexed symbols — a different answer from "no such path" (#334). */
|
|
632
|
+
| {
|
|
594
633
|
kind: "none";
|
|
595
634
|
target: string;
|
|
635
|
+
realFile?: boolean;
|
|
596
636
|
};
|
|
597
637
|
export interface StaleRecord {
|
|
598
638
|
kind: string;
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { resolve, join, dirname, isAbsolute, relative } from "node:path";
|
|
14
14
|
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
15
|
-
import { toPosixTarget, hunchPathsForDir } from "../core/paths.js";
|
|
15
|
+
import { toPosixTarget, repoRelativeTarget, isRepoFile, hunchPathsForDir } from "../core/paths.js";
|
|
16
16
|
import { ENTITY_KINDS } from "../core/types.js";
|
|
17
17
|
import { openDb, withTx } from "./db.js";
|
|
18
18
|
import { RESET_SQL, embedHash } from "./schema.js";
|
|
19
19
|
import { selectEmbedder } from "./embedder.js";
|
|
20
20
|
import { JsonStore } from "./jsonStore.js";
|
|
21
21
|
import { gitCommonDir, gitWorktreeRoot, isolatedHeadSha, sameGitPublication, scopedLastChangeDates, } from "../extractors/git.js";
|
|
22
|
-
import { pathMatchesGlob, pathsRelated } from "../core/glob.js";
|
|
22
|
+
import { pathMatchesGlob, pathsRelated, isIndexedPath, matchSymbolsTiered } from "../core/glob.js";
|
|
23
23
|
import { cochangeFor } from "../core/cochange.js";
|
|
24
24
|
import { withServedDatabase } from "../core/served.js";
|
|
25
25
|
import { normalizePath, rankTaskRecords, recordIdsOf, selectLatestTasks, selectTaskSlots } from "../core/taskRanking.js";
|
|
@@ -1178,13 +1178,40 @@ export class HunchStore {
|
|
|
1178
1178
|
}
|
|
1179
1179
|
return hits;
|
|
1180
1180
|
}
|
|
1181
|
+
/** The WIDE definition of "is `t` a real path?" — the gate used by the matchers
|
|
1182
|
+
* that ATTRIBUTE records to a target silently (why(), liveFindingsFor, tasksFor,
|
|
1183
|
+
* `resolveSymbols` in the MCP server). They also run for a file an agent is
|
|
1184
|
+
* about to create, where the covering component glob is the only signal there
|
|
1185
|
+
* is, so the answer must not depend on the file already being on disk. The
|
|
1186
|
+
* resolvers that NAME the file they resolved to (resolveNodeIds, structure())
|
|
1187
|
+
* deliberately use the narrower `isRepoFile` instead.
|
|
1188
|
+
*
|
|
1189
|
+
* Graph data FIRST (a symbol's exact file, or an indexed component's path glob),
|
|
1190
|
+
* so a deleted-but-still-indexed path and a time-travel (`asOf`) query answer the
|
|
1191
|
+
* same as before (issue #299). The working tree is consulted only as a LAST
|
|
1192
|
+
* resort, for the case the index cannot see: a real file with zero tree-sitter
|
|
1193
|
+
* symbols and no covering component glob, which otherwise suffix-leaked an
|
|
1194
|
+
* unrelated same-basename file's records (issue #334). When the working tree is
|
|
1195
|
+
* unavailable or the file is gone, behaviour is exactly the pre-#334 behaviour.
|
|
1196
|
+
*
|
|
1197
|
+
* `t` must already be repo-relative (`repoRelativeTarget`). `graph` lets a caller
|
|
1198
|
+
* that has already narrowed the graph (why()'s `canRead` visibility filter) ask
|
|
1199
|
+
* the question over exactly the records it may see, rather than all of them. */
|
|
1200
|
+
isKnownPath(t, graph) {
|
|
1201
|
+
const symbols = graph?.symbols ?? this.recs("symbols");
|
|
1202
|
+
const components = graph?.components ?? this.recs("components");
|
|
1203
|
+
return isIndexedPath(t, symbols.map((s) => s.file), components.map((c) => c.paths))
|
|
1204
|
+
|| isRepoFile(this.paths.root, t);
|
|
1205
|
+
}
|
|
1181
1206
|
/** All decisions/bugs/constraints/symbols/components touching a file path or
|
|
1182
1207
|
* symbol name (hunch_why). Pass `{ asOf }` (an ISO instant) to TIME-TRAVEL:
|
|
1183
1208
|
* return only decisions/constraints whose valid-time window contained that
|
|
1184
1209
|
* instant — "what did we believe as of commit X?". Omit `asOf` for the full,
|
|
1185
1210
|
* history-inclusive view (backward-compatible default). */
|
|
1186
1211
|
why(target, opts = {}) {
|
|
1187
|
-
target
|
|
1212
|
+
// An absolute target (an agent's edit-payload path, verbatim) never matched
|
|
1213
|
+
// the repo-relative stored file paths below (issue #299).
|
|
1214
|
+
target = repoRelativeTarget(target, this.paths.root);
|
|
1188
1215
|
const decisions = this.recs("decisions").filter(opts.canRead ?? (() => true));
|
|
1189
1216
|
const bugs = this.recs("bugs").filter(opts.canRead ?? (() => true));
|
|
1190
1217
|
const constraints = this.recs("constraints").filter(opts.canRead ?? (() => true));
|
|
@@ -1194,11 +1221,24 @@ export class HunchStore {
|
|
|
1194
1221
|
// pathsRelated, not bare endsWith: "scenario.ts".endsWith("io.ts") is true,
|
|
1195
1222
|
// so an unanchored suffix pulled unrelated files' records into why()/the
|
|
1196
1223
|
// pre-edit grounding block (issue #32). Segment-anchored matching only.
|
|
1197
|
-
|
|
1224
|
+
//
|
|
1225
|
+
// A target that IS a path Hunch's index already knows about (README.md,
|
|
1226
|
+
// vscode-extension/index.ts, ...) names exactly one file — suffix-matching
|
|
1227
|
+
// it too pulls in every OTHER file that merely shares a basename (issue
|
|
1228
|
+
// #299: root index.ts delivered vscode-extension/index.ts's rules and vice
|
|
1229
|
+
// versa). "Known to the index" is answered from already-loaded graph data
|
|
1230
|
+
// first (a symbol's exact file, or an indexed component's path glob — so a
|
|
1231
|
+
// symbol-less indexed file still counts as real), and only then from the
|
|
1232
|
+
// working tree, for a real file the index cannot see at all (issue #334).
|
|
1233
|
+
// See isKnownPath. Suffix matching stays reserved for a target that is not
|
|
1234
|
+
// itself a real path, e.g. a short/partial reference like "x/scenario.ts".
|
|
1235
|
+
const indexed = this.isKnownPath(target, { symbols, components });
|
|
1236
|
+
const suffixMatch = (file) => !indexed && pathsRelated(file, target);
|
|
1237
|
+
const matchedSymbols = matchSymbolsTiered(target, symbols, indexed);
|
|
1198
1238
|
const symIds = new Set(matchedSymbols.map((s) => s.id));
|
|
1199
1239
|
const fileSet = new Set(matchedSymbols.map((s) => s.file));
|
|
1200
1240
|
const isPath = target.includes("/") || target.includes(".");
|
|
1201
|
-
const fileMatch = (files) => files.some((f) => f === target || (isPath &&
|
|
1241
|
+
const fileMatch = (files) => files.some((f) => f === target || (isPath && suffixMatch(f)) || fileSet.has(f));
|
|
1202
1242
|
return {
|
|
1203
1243
|
target,
|
|
1204
1244
|
decisions: decisions.filter((d) => (fileMatch(d.related_files) || d.related_components.some((c) => components.find((x) => x.id === c && fileMatch(x.paths))))
|
|
@@ -1320,10 +1360,23 @@ export class HunchStore {
|
|
|
1320
1360
|
return c?.v ?? id;
|
|
1321
1361
|
}
|
|
1322
1362
|
/** Resolve a free-form target (symbol id / name / file path, component id / name)
|
|
1323
|
-
* to graph node ids — symbols win over components, exact file before suffix.
|
|
1363
|
+
* to graph node ids — symbols win over components, exact file before suffix.
|
|
1364
|
+
* Shares why()/resolveSymbols' tiered matcher rather than one un-tiered SQL OR:
|
|
1365
|
+
* the flat query matched every tier at once, so a root `index.ts` also returned
|
|
1366
|
+
* `a/index.ts`'s symbols, and an absolute target matched nothing at all (#335).
|
|
1367
|
+
*
|
|
1368
|
+
* The suffix gate here is `isRepoFile`, NOT the wider `isKnownPath`: the
|
|
1369
|
+
* exact-file tier has already run, so the only case left is a real on-disk file
|
|
1370
|
+
* with zero symbols (#334). Glob coverage alone would wrongly block a target
|
|
1371
|
+
* that no file actually carries — with a `src/**` component, `src/x.ts` living
|
|
1372
|
+
* at `packages/a/src/x.ts` stopped resolving. This resolver names the file it
|
|
1373
|
+
* resolved to in its result, so a suffix resolution is visible and only a file
|
|
1374
|
+
* that really exists may block it; why()/the pre-edit hook attribute records
|
|
1375
|
+
* silently and also run for a file about to be created, so they keep the wider
|
|
1376
|
+
* `isKnownPath`. */
|
|
1324
1377
|
resolveNodeIds(target) {
|
|
1325
|
-
const t =
|
|
1326
|
-
const sym = this.
|
|
1378
|
+
const t = repoRelativeTarget(target, this.paths.root);
|
|
1379
|
+
const sym = matchSymbolsTiered(t, this.recs("symbols"), isRepoFile(this.paths.root, t)).slice(0, 20);
|
|
1327
1380
|
if (sym.length)
|
|
1328
1381
|
return sym.map((r) => r.id);
|
|
1329
1382
|
const cmp = this.db.prepare(`SELECT id FROM components WHERE id = ? OR name = ? LIMIT 5`).all(t, t);
|
|
@@ -1380,10 +1433,27 @@ export class HunchStore {
|
|
|
1380
1433
|
dirs: [...dirs.entries()].map(([dir, v]) => ({ dir, ...v })).sort((a, b) => b.symbols - a.symbols),
|
|
1381
1434
|
};
|
|
1382
1435
|
}
|
|
1383
|
-
|
|
1436
|
+
// An absolute target (an agent's edit-payload path, verbatim) never matched the
|
|
1437
|
+
// repo-relative stored file paths below and fell all the way to "none" (#335).
|
|
1438
|
+
const t = repoRelativeTarget(target.trim(), this.paths.root).replace(/\/+$/, "");
|
|
1384
1439
|
// FILE: exact path or unique suffix
|
|
1385
1440
|
const fileRows = this.db.prepare(`SELECT id, name, kind, loc, fan_in, fan_out FROM symbols WHERE file = ? ORDER BY fan_in DESC, name`).all(t);
|
|
1386
|
-
|
|
1441
|
+
// Only the SUFFIX fallback is gated, and by `isRepoFile` rather than the wider
|
|
1442
|
+
// `isKnownPath`: the exact-path tier above has already run, so the only case
|
|
1443
|
+
// left is a real on-disk file with zero symbols, whose outline must not come
|
|
1444
|
+
// from an unrelated same-basename file (#334). Glob coverage alone is not
|
|
1445
|
+
// existence — with a `src/**` component, `src/x.ts` living at
|
|
1446
|
+
// `packages/a/src/x.ts` would stop resolving. structure() names the file it
|
|
1447
|
+
// resolved to, so a suffix resolution is visible and only a file that really
|
|
1448
|
+
// exists may block it; why()/the pre-edit hook attribute records silently and
|
|
1449
|
+
// also run for a file about to be created, so they keep `isKnownPath`.
|
|
1450
|
+
// Directory targets are unaffected — a directory is never an `isRepoFile`
|
|
1451
|
+
// (regular files only), so they keep flowing to the dir tier below.
|
|
1452
|
+
const fileHit = fileRows.length
|
|
1453
|
+
? t
|
|
1454
|
+
: isRepoFile(this.paths.root, t)
|
|
1455
|
+
? []
|
|
1456
|
+
: this.db.prepare(`SELECT DISTINCT file FROM symbols WHERE file LIKE ?`).all(`%/${t}`).map((r) => r.file);
|
|
1387
1457
|
const file = typeof fileHit === "string" ? fileHit : fileHit.length === 1 ? fileHit[0] : null;
|
|
1388
1458
|
if (file) {
|
|
1389
1459
|
const syms = fileRows.length ? fileRows : this.db.prepare(`SELECT id, name, kind, loc, fan_in, fan_out FROM symbols WHERE file = ? ORDER BY fan_in DESC, name`).all(file);
|
|
@@ -1412,7 +1482,10 @@ export class HunchStore {
|
|
|
1412
1482
|
matches: named.map((m) => ({ ...m, callers: this.edgeNeighbors(m.id, "in", 6), callees: this.edgeNeighbors(m.id, "out", 6) })),
|
|
1413
1483
|
};
|
|
1414
1484
|
}
|
|
1415
|
-
|
|
1485
|
+
// A real on-disk file with zero indexed symbols reaches "none" too (#334). Flag
|
|
1486
|
+
// it so the renderer doesn't tell the user it isn't a known file when we just
|
|
1487
|
+
// stat'd it.
|
|
1488
|
+
return { kind: "none", target: t, ...(isRepoFile(this.paths.root, t) ? { realFile: true } : {}) };
|
|
1416
1489
|
}
|
|
1417
1490
|
/** Labelled one-hop edge neighbors of a node ("in" = who reaches it, "out" = what it reaches). */
|
|
1418
1491
|
edgeNeighbors(id, dir, limit) {
|
|
@@ -1426,6 +1499,10 @@ export class HunchStore {
|
|
|
1426
1499
|
* longer enforced. Pass `{ asOf }` to instead return the invariants in force at
|
|
1427
1500
|
* that instant (time-travel: "what must I not have broken as of commit X?"). */
|
|
1428
1501
|
checkConstraints(scope, opts = {}) {
|
|
1502
|
+
// Constraint scopes are always repo-relative; an absolute target (an
|
|
1503
|
+
// agent's edit-payload path, verbatim) matched nothing even when a
|
|
1504
|
+
// blocking rule plainly applied (issue #296).
|
|
1505
|
+
scope = repoRelativeTarget(scope, this.paths.root);
|
|
1429
1506
|
const all = this.recs("constraints");
|
|
1430
1507
|
const asOf = opts.asOf;
|
|
1431
1508
|
return all
|
|
@@ -1439,21 +1516,29 @@ export class HunchStore {
|
|
|
1439
1516
|
* glob, and the queried scope may be either too. Advisory only — findings never
|
|
1440
1517
|
* enter any block path. Sorted worst-first, then id for stable output. */
|
|
1441
1518
|
liveFindingsFor(scope) {
|
|
1442
|
-
|
|
1519
|
+
// Same absolute-target and same-basename-leak gaps as why() (issue #299): an
|
|
1520
|
+
// un-rewritten absolute scope never matched the repo-relative `affected_files`
|
|
1521
|
+
// below, and an un-guarded suffix fallback could pull in an unrelated file
|
|
1522
|
+
// that merely shares a basename. Normalize once and reuse the same "is this
|
|
1523
|
+
// path known to the index" guard.
|
|
1524
|
+
const t = repoRelativeTarget(scope, this.paths.root);
|
|
1525
|
+
const indexed = this.isKnownPath(t);
|
|
1443
1526
|
const live = (f) => f.triage === "open" || f.triage === "accepted-risk" || f.triage === "scheduled";
|
|
1444
1527
|
return this.recs("findings")
|
|
1445
1528
|
.filter(live)
|
|
1446
|
-
.filter((f) => f.affected_files.some((af) => pathMatchesGlob(t, af) || pathMatchesGlob(af, t) || pathsRelated(toPosixTarget(af), t))
|
|
1447
|
-
|| f.affected_symbols.some((s) => s ===
|
|
1529
|
+
.filter((f) => f.affected_files.some((af) => pathMatchesGlob(t, af) || pathMatchesGlob(af, t) || (!indexed && pathsRelated(toPosixTarget(af), t)))
|
|
1530
|
+
|| f.affected_symbols.some((s) => s === t))
|
|
1448
1531
|
.sort((a, b) => (SEV_FINDING[b.severity] ?? 0) - (SEV_FINDING[a.severity] ?? 0) || a.id.localeCompare(b.id));
|
|
1449
1532
|
}
|
|
1450
1533
|
/** Finished agent tasks that touched a file or glob (rule-checked changes and
|
|
1451
1534
|
* denied edits), newest first. Graph memory, so it spans machines and survives
|
|
1452
1535
|
* the local ledger's retention window. */
|
|
1453
1536
|
tasksFor(scope, limit = 8) {
|
|
1454
|
-
|
|
1537
|
+
// Same normalization + known-path suffix guard as liveFindingsFor/why() (issue #299).
|
|
1538
|
+
const t = repoRelativeTarget(scope, this.paths.root);
|
|
1539
|
+
const indexed = this.isKnownPath(t);
|
|
1455
1540
|
return this.recs("tasks")
|
|
1456
|
-
.filter((r) => r.files.some((f) => pathMatchesGlob(t, f) || pathMatchesGlob(f, t) || pathsRelated(toPosixTarget(f), t)))
|
|
1541
|
+
.filter((r) => r.files.some((f) => pathMatchesGlob(t, f) || pathMatchesGlob(f, t) || (!indexed && pathsRelated(toPosixTarget(f), t))))
|
|
1457
1542
|
.sort((a, b) => b.finished_at.localeCompare(a.finished_at) || a.id.localeCompare(b.id))
|
|
1458
1543
|
.slice(0, Math.max(1, limit));
|
|
1459
1544
|
}
|
|
@@ -2048,7 +2133,10 @@ export class HunchStore {
|
|
|
2048
2133
|
* a task on `target`, ordered by what matters most — invariants first, then the
|
|
2049
2134
|
* why, then blast radius and bug history — trimmed to a rough token budget. */
|
|
2050
2135
|
assembleContext(target, budget = 1500, opts = {}) {
|
|
2051
|
-
target
|
|
2136
|
+
// An absolute target (an agent's edit-payload path, verbatim) must be
|
|
2137
|
+
// repo-relative BEFORE it reaches why()/liveFindingsFor() below, or an
|
|
2138
|
+
// absolute path never matches the repo-relative stored data (issue #299).
|
|
2139
|
+
target = repoRelativeTarget(target, this.paths.root);
|
|
2052
2140
|
const w = this.why(target, opts);
|
|
2053
2141
|
const symIds = w.symbols.map((s) => s.id);
|
|
2054
2142
|
const blast = new Map();
|
|
@@ -78,10 +78,12 @@ export declare class JsonStore {
|
|
|
78
78
|
* The long-lived MCP server and CLI hooks write the same `.hunch/` concurrently;
|
|
79
79
|
* two unsynchronized RMWs over index.json each read the same base array and the
|
|
80
80
|
* second rename silently erases the first's record. `mkdirSync` is the atomic
|
|
81
|
-
* acquire (EEXIST = held). A stale lock (killed process
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
81
|
+
* acquire (EEXIST = held). A stale lock (killed process, or one whose pid was
|
|
82
|
+
* RECYCLED) is taken over behind an exclusive `.reclaim` claim — see
|
|
83
|
+
* `takeOverRmwLock`, because judging and removing are two steps and two
|
|
84
|
+
* contenders could otherwise both "win". Against a live contender we wait
|
|
85
|
+
* briefly and then refuse the write. Proceeding without the lock would
|
|
86
|
+
* reintroduce the record-loss race this mutex exists to prevent. */
|
|
85
87
|
private withSingleFileLock;
|
|
86
88
|
/** Write a single record (validated) to its JSON file / into the index array. */
|
|
87
89
|
put<K extends EntityKind>(kind: K, record: EntityFor[K]): EntityFor[K];
|