@davesheffer/hunch 1.29.0 → 1.31.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 +9 -0
- package/dist/cli/automaticReviewMemory.js +124 -0
- package/dist/cli/index.js +80 -10
- package/dist/cli/invocation.js +9 -0
- package/dist/cli/reviewMemory.js +34 -0
- package/dist/cli/reviewMemoryProvider.js +40 -0
- package/dist/cli/serve.js +44 -0
- package/dist/constitution/experimentRunner.js +3 -1
- package/dist/core/automaticReviewMemory.js +141 -0
- package/dist/core/reviewMemory.js +100 -0
- package/dist/core/stateContract.js +9 -0
- package/dist/core/stateRecords.js +30 -0
- package/dist/extractors/diff.js +26 -21
- package/dist/extractors/git.js +7 -1
- package/dist/extractors/languages.js +8 -0
- package/dist/mcp/server.js +53 -44
- package/dist/store/replay.js +153 -0
- package/dist/store/stateBinding.js +160 -12
- package/dist/synthesis/cliAdapter.js +168 -0
- package/dist/synthesis/initiator.js +58 -0
- package/dist/synthesis/provider.js +84 -48
- package/dist/synthesis/synthesize.js +37 -16
- package/package.json +3 -1
- package/server.json +2 -2
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** Review text is evidence, never executable instructions or policy authority. */
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { canonicalHash } from "../constitution/canonical.js";
|
|
4
|
+
import { buildCorrectionConstraint } from "./correction.js";
|
|
5
|
+
const repositorySchema = z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/);
|
|
6
|
+
const pathSchema = z.string().min(1).max(500).refine(p => !p.startsWith("/") && !/[\\:*?\[\]{}\x00-\x1f]/.test(p)
|
|
7
|
+
&& p.split("/").every(part => part !== ".." && part !== "." && part !== ""), "expected a literal repository-relative file");
|
|
8
|
+
const commentSchema = z.object({
|
|
9
|
+
id: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
|
10
|
+
body: z.string().trim().min(1).max(32000),
|
|
11
|
+
path: pathSchema,
|
|
12
|
+
html_url: z.string().url(),
|
|
13
|
+
commit_id: z.string().regex(/^[a-f0-9]{40,64}$/),
|
|
14
|
+
created_at: z.string().datetime({ offset: true }),
|
|
15
|
+
updated_at: z.string().datetime({ offset: true }),
|
|
16
|
+
in_reply_to_id: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional(),
|
|
17
|
+
user: z.object({ login: z.string().min(1).max(100), type: z.enum(["User", "Bot"]) }),
|
|
18
|
+
});
|
|
19
|
+
/** Accept GitHub REST review-comment exports, including gh --paginate --slurp pages. */
|
|
20
|
+
export function prepareReviewMemory(repository, input) {
|
|
21
|
+
repositorySchema.parse(repository);
|
|
22
|
+
if (!Array.isArray(input))
|
|
23
|
+
throw new Error("expected a GitHub review-comment array");
|
|
24
|
+
const comments = z.array(commentSchema).max(10000).parse(input.flat());
|
|
25
|
+
const unique = new Map();
|
|
26
|
+
for (const comment of comments) {
|
|
27
|
+
const url = new URL(comment.html_url);
|
|
28
|
+
if (url.origin !== "https://github.com" || url.username || url.password || url.search
|
|
29
|
+
|| !new RegExp(`^/${repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/pull/[1-9][0-9]*$`, "i").test(url.pathname)
|
|
30
|
+
|| url.hash !== `#discussion_r${comment.id}`)
|
|
31
|
+
throw new Error(`comment ${comment.id} does not belong to ${repository}`);
|
|
32
|
+
const previous = unique.get(comment.id);
|
|
33
|
+
if (previous && canonicalHash(previous) !== canonicalHash(comment))
|
|
34
|
+
throw new Error(`conflicting versions of comment ${comment.id}`);
|
|
35
|
+
unique.set(comment.id, comment);
|
|
36
|
+
}
|
|
37
|
+
const groups = new Map();
|
|
38
|
+
let excluded = 0;
|
|
39
|
+
for (const comment of unique.values()) {
|
|
40
|
+
if (comment.user.type === "Bot") {
|
|
41
|
+
excluded++;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const rootId = comment.in_reply_to_id ?? comment.id;
|
|
45
|
+
const group = groups.get(rootId) ?? [];
|
|
46
|
+
group.push(comment);
|
|
47
|
+
groups.set(rootId, group);
|
|
48
|
+
}
|
|
49
|
+
const candidates = [];
|
|
50
|
+
for (const [rootId, group] of groups) {
|
|
51
|
+
// Do not misrepresent a reply as the original request when an export is partial.
|
|
52
|
+
const root = group.find(comment => comment.id === rootId && !comment.in_reply_to_id);
|
|
53
|
+
if (!root)
|
|
54
|
+
throw new Error(`missing human root comment ${rootId}; export the complete thread`);
|
|
55
|
+
if (group.some(comment => comment.path !== root.path || new URL(comment.html_url).pathname !== new URL(root.html_url).pathname)) {
|
|
56
|
+
throw new Error(`inconsistent thread ${rootId}`);
|
|
57
|
+
}
|
|
58
|
+
group.sort((a, b) => a.id - b.id);
|
|
59
|
+
const evidenceHash = canonicalHash({ repository: repository.toLowerCase(), comments: group });
|
|
60
|
+
candidates.push({ id: `review_${rootId}`, evidence_hash: evidenceHash, file: root.path, comments: group });
|
|
61
|
+
}
|
|
62
|
+
candidates.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
63
|
+
return { schema: "hunch.review-memory/1", repository: repository.toLowerCase(), authority: "none", candidates, excluded_bots: excluded };
|
|
64
|
+
}
|
|
65
|
+
export function validateReviewPacket(input) {
|
|
66
|
+
const packet = z.object({
|
|
67
|
+
schema: z.literal("hunch.review-memory/1"), repository: repositorySchema,
|
|
68
|
+
authority: z.literal("none"), excluded_bots: z.number().int().nonnegative(),
|
|
69
|
+
candidates: z.array(z.object({ id: z.string(), evidence_hash: z.string(), file: pathSchema, comments: z.array(commentSchema).min(1) }).strict()).max(10000),
|
|
70
|
+
}).strict().parse(input);
|
|
71
|
+
const rebuilt = prepareReviewMemory(packet.repository, packet.candidates.flatMap(c => c.comments));
|
|
72
|
+
if (canonicalHash(rebuilt.candidates) !== canonicalHash(packet.candidates))
|
|
73
|
+
throw new Error("review packet evidence hash or membership changed; prepare it again");
|
|
74
|
+
return packet;
|
|
75
|
+
}
|
|
76
|
+
/** A separate, explicit selection supplies the actual rule and how to check it. */
|
|
77
|
+
export function compileReviewRules(packetInput, selections, now) {
|
|
78
|
+
const packet = validateReviewPacket(packetInput);
|
|
79
|
+
const rules = z.array(z.object({
|
|
80
|
+
candidate_id: z.string(), evidence_hash: z.string(),
|
|
81
|
+
rule: z.string().trim().min(10).max(2000),
|
|
82
|
+
check: z.string().trim().min(10).max(4000),
|
|
83
|
+
}).strict()).min(1).max(100).parse(selections);
|
|
84
|
+
const results = rules.map(selection => {
|
|
85
|
+
const candidate = packet.candidates.find(c => c.id === selection.candidate_id);
|
|
86
|
+
if (!candidate || candidate.evidence_hash !== selection.evidence_hash)
|
|
87
|
+
throw new Error(`stale or missing review selection ${selection.candidate_id}`);
|
|
88
|
+
const record = buildCorrectionConstraint({ rule: selection.rule, scope_hint_file: candidate.file,
|
|
89
|
+
severity: "warning", vouched: false, rationale: `Review check: ${selection.check}` }, now);
|
|
90
|
+
// Prose cannot silently create a regex/import matcher or earn a human signature.
|
|
91
|
+
record.forbids = null;
|
|
92
|
+
record.provenance.evidence = [packet.repository, candidate.id, candidate.evidence_hash,
|
|
93
|
+
...candidate.comments.flatMap(c => [c.html_url, `git:${c.commit_id}`])];
|
|
94
|
+
return record;
|
|
95
|
+
});
|
|
96
|
+
if (new Set(results.map(r => r.id)).size !== results.length)
|
|
97
|
+
throw new Error("duplicate rule statements; select one thread per rule");
|
|
98
|
+
return results;
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=reviewMemory.js.map
|
|
@@ -28,6 +28,7 @@ import { createHash } from "node:crypto";
|
|
|
28
28
|
import { z } from "zod";
|
|
29
29
|
import { compareCodeUnits } from "./canonicalOrder.js";
|
|
30
30
|
import { DELIVERY_PROFILES } from "./delivery.js";
|
|
31
|
+
import { isHumanConfirmed as sourceIsHumanConfirmed } from "./strictgate.js";
|
|
31
32
|
import { ScopeSchema, scopePath, DependencyRefSchema, ExternalRefSchema, RECEIPT_SCHEMA_VERSION, COMMITMENT_SCHEMA_VERSION, DERIVED_SCHEMA_VERSION, ENTITY_SCHEMA_VERSION, RELATIONSHIP_SCHEMA_VERSION, } from "./stateRecords.js";
|
|
32
33
|
export * from "./stateRecords.js";
|
|
33
34
|
export const STATE_CONTRACT_VERSION = "nuryel.state/1";
|
|
@@ -234,9 +235,17 @@ export const STATE_INVARIANTS = [
|
|
|
234
235
|
{ id: "one-live-decision-per-topic", statement: "A second live decision on a topic is refused with the incumbent named; supersession is explicit." },
|
|
235
236
|
{ id: "external-truth-stays-external", statement: "External systems remain authoritative for their own content; Nuryel holds credential-free pointers, versions and hashes, never mirrored bodies." },
|
|
236
237
|
{ id: "derived-state-carries-dependencies", statement: "A derived statement without dependencies cannot be invalidated and is therefore not state." },
|
|
238
|
+
{ id: "one-entity-per-external-ref", statement: "One external record is one entity in a partition: a second active entity carrying an external key an incumbent already carries is refused with the incumbent named, and a subject written as that record's external key is refused with the entity's id named. Identity is explicit refs, never similarity; merge is explicit — a retired entity names the survivor in `merged_into`, the ledger holds the `retired` event, nothing under the old id is rewritten and reads resolve to the survivor — and split is the explicit reverse; never a silent rewrite." },
|
|
239
|
+
{ id: "human-correction-outranks-agent-writes", statement: "A record a human confirmed is never overwritten or superseded by an agent or service principal: the agent may replay it, write derived state back stale with the external cause that moved, or close a commitment with a receipt on record. Changing what the human said takes a human." },
|
|
237
240
|
{ id: "derived-state-writer-owns-currentness", statement: "No source writes the drawer. The writer of a derived statement owns keeping its dependencies true: re-validate them on a schedule or on a source event, and write the statement back stale with the moved pointer as cause when one no longer holds. An agent that will not do this must not write derived state." },
|
|
238
241
|
];
|
|
239
242
|
const grantKey = (scope) => scopePath(scope);
|
|
243
|
+
/** The memory supply chain's top tier: a record whose provenance a human signed. Same tier rule
|
|
244
|
+
* as the strict gate's (strictgate.isHumanConfirmed), applied to a record instead of a source. */
|
|
245
|
+
export function isHumanConfirmed(record) {
|
|
246
|
+
const source = record?.provenance?.source;
|
|
247
|
+
return typeof source === "string" && sourceIsHumanConfirmed(source);
|
|
248
|
+
}
|
|
240
249
|
/** authorization-before-retrieval, checked on the way OUT as well: nothing in a read response
|
|
241
250
|
* may sit outside the principal's grants. Bindings must also filter on the way in. */
|
|
242
251
|
export function assertReadWithinGrants(principal, response) {
|
|
@@ -44,6 +44,24 @@ export const ExternalRefSchema = z.object({
|
|
|
44
44
|
observed_at: z.string().regex(ISO),
|
|
45
45
|
locator: credentialFree("external locator").optional(),
|
|
46
46
|
}).strict();
|
|
47
|
+
/** The external system's own key, canonicalized so two writers that copied it from the same
|
|
48
|
+
* system agree byte for byte: Unicode NFC, trimmed, internal whitespace collapsed. Case is
|
|
49
|
+
* preserved — the key belongs to the external system, and folding it could merge two of its
|
|
50
|
+
* records. No other guessing: identity here is explicit refs, never similarity. */
|
|
51
|
+
export function canonicalObjectKey(key) {
|
|
52
|
+
return key.normalize("NFC").trim().replace(/\s+/g, " ");
|
|
53
|
+
}
|
|
54
|
+
/** The identity of an external record across writers: system, type and canonical key. Two
|
|
55
|
+
* entities in one partition that carry the same external key are the same thing. */
|
|
56
|
+
export function externalKey(ref) {
|
|
57
|
+
return `${ref.system}/${ref.object_type}/${canonicalObjectKey(ref.object_key)}`;
|
|
58
|
+
}
|
|
59
|
+
/** The subject an external record is known by, the convention the read verb already uses for
|
|
60
|
+
* receipts (`event:26904`): the object type and the canonical key. When an entity in the
|
|
61
|
+
* partition carries the ref, that entity's id is the subject and this form resolves to it. */
|
|
62
|
+
export function subjectOfRef(ref) {
|
|
63
|
+
return `${ref.object_type}:${canonicalObjectKey(ref.object_key)}`;
|
|
64
|
+
}
|
|
47
65
|
/** What a derived statement rests on. Exactly what a currentness check re-validates. */
|
|
48
66
|
export const DependencyRefSchema = z.discriminatedUnion("kind", [
|
|
49
67
|
/** `scope` (additive) points into ANOTHER partition — the repository decision an
|
|
@@ -124,10 +142,22 @@ export const ExternalEntitySchema = z.object({
|
|
|
124
142
|
refs: z.array(ExternalRefSchema).min(1).max(64),
|
|
125
143
|
attributes: z.record(z.string().max(128), AttributeValue).default({}),
|
|
126
144
|
lifecycle: z.enum(["active", "deprecated", "retired"]).default("active"),
|
|
145
|
+
/** Audited merge (additive): this entity was folded into another. Set only on a retired
|
|
146
|
+
* entity; the survivor must be an active entity on record. Nothing under the retired id is
|
|
147
|
+
* rewritten — reads resolve the old id and its keys to the survivor, and the ledger holds
|
|
148
|
+
* the `retired` event with its provenance. A split is the explicit reverse: retire or
|
|
149
|
+
* re-key the survivor, then write the entity active again without `merged_into`. */
|
|
150
|
+
merged_into: z.string().min(3).max(2048).optional(),
|
|
127
151
|
provenance: ProvenanceSchema,
|
|
128
152
|
created_at: z.string().regex(ISO),
|
|
129
153
|
updated_at: z.string().regex(ISO),
|
|
130
154
|
}).strict().superRefine((entity, ctx) => {
|
|
155
|
+
if (entity.merged_into !== undefined) {
|
|
156
|
+
if (entity.lifecycle !== "retired")
|
|
157
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["merged_into"], message: "a merged entity is retired; the survivor stays active" });
|
|
158
|
+
if (entity.merged_into === entity.id)
|
|
159
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["merged_into"], message: "an entity cannot merge into itself" });
|
|
160
|
+
}
|
|
131
161
|
const prefix = `${entity.kind}:`;
|
|
132
162
|
const key = entity.id.startsWith(prefix) ? entity.id.slice(prefix.length) : "";
|
|
133
163
|
if (!key.trim() || entity.id !== resourceId(entity.kind, key)) {
|
package/dist/extractors/diff.js
CHANGED
|
@@ -26,7 +26,7 @@ const DECL_PATTERNS = [
|
|
|
26
26
|
// `class Foo(Bar):` header too, and — since declOf() returns on the first match —
|
|
27
27
|
// always wins for Python class lines before any Python-specific pattern would run.
|
|
28
28
|
];
|
|
29
|
-
import { languageFor } from "./languages.js";
|
|
29
|
+
import { languageFor, isSubstantive } from "./languages.js";
|
|
30
30
|
const IMPORT_RE = /^\s*import\s+(?:[^'"]*from\s+)?['"]([^'"]+)['"]/;
|
|
31
31
|
const CONT_IMPORT_RE = /^\s*\}?\s*from\s+['"]([^'"]+)['"]/; // multi-line: "} from 'x'"
|
|
32
32
|
const REQUIRE_RE = /\brequire\(\s*['"]([^'"]+)['"]\s*\)/;
|
|
@@ -114,7 +114,7 @@ export function analyzeDiff(diff) {
|
|
|
114
114
|
else if (raw.startsWith("rename to ") || raw.startsWith("copy to ")) {
|
|
115
115
|
const to = raw.slice(raw.indexOf(" to ") + 4).trim();
|
|
116
116
|
curFile = to;
|
|
117
|
-
if (
|
|
117
|
+
if (isSubstantive(to))
|
|
118
118
|
filesRenamed.push({ from: renameFrom, to });
|
|
119
119
|
}
|
|
120
120
|
else if (raw.startsWith("--- ")) {
|
|
@@ -126,7 +126,7 @@ export function analyzeDiff(diff) {
|
|
|
126
126
|
const p = raw.slice(4).trim();
|
|
127
127
|
if (p !== "/dev/null")
|
|
128
128
|
curFile = stripAB(p); // new path preferred
|
|
129
|
-
if (
|
|
129
|
+
if (isSubstantive(curFile)) {
|
|
130
130
|
if (curAdded)
|
|
131
131
|
filesAdded.add(curFile);
|
|
132
132
|
else if (curDeleted)
|
|
@@ -138,45 +138,50 @@ export function analyzeDiff(diff) {
|
|
|
138
138
|
// ---- inside a hunk: content lines ----
|
|
139
139
|
if (raw.startsWith("+")) {
|
|
140
140
|
const body = raw.slice(1);
|
|
141
|
-
// Raw added lines are captured for EVERY file, before the
|
|
141
|
+
// Raw added lines are captured for EVERY file, before the substantive gate:
|
|
142
142
|
// content-matched constraints and Veto tripwires are not code-only rules
|
|
143
143
|
// (a blocking invariant legitimately scopes .github/workflows/**, *.sql,
|
|
144
144
|
// Dockerfile). Skipping them here left `scopedAdded` empty, which
|
|
145
145
|
// buildCheckReport reads as "cannot prove a violation ⇒ complies" — so the
|
|
146
146
|
// pre-edit hook denied the edit while `hunch check --strict` passed the
|
|
147
|
-
// very commit that landed it.
|
|
148
|
-
//
|
|
147
|
+
// very commit that landed it. The churn counters below now also include
|
|
148
|
+
// prose (isSubstantive, issue #12); symbol/import extraction stays
|
|
149
|
+
// code-only (isCode/languageFor) since declarations are a code concept.
|
|
149
150
|
let lines = addedLinesBy.get(curFile);
|
|
150
151
|
if (!lines) {
|
|
151
152
|
lines = [];
|
|
152
153
|
addedLinesBy.set(curFile, lines);
|
|
153
154
|
}
|
|
154
155
|
lines.push(body);
|
|
155
|
-
if (!
|
|
156
|
+
if (!isSubstantive(curFile))
|
|
156
157
|
continue;
|
|
157
158
|
addedLines++;
|
|
158
159
|
if (!curAdded && !curDeleted)
|
|
159
160
|
filesModified.add(curFile);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
161
|
+
if (isCode(curFile)) {
|
|
162
|
+
const d = declOf(body);
|
|
163
|
+
if (d)
|
|
164
|
+
declsFor(curFile)?.added.set(d.name, d);
|
|
165
|
+
const imp = importOf(body);
|
|
166
|
+
if (imp && !imp.startsWith("."))
|
|
167
|
+
addedImports.add(imp);
|
|
168
|
+
}
|
|
166
169
|
}
|
|
167
170
|
else if (raw.startsWith("-")) {
|
|
168
|
-
if (!
|
|
171
|
+
if (!isSubstantive(curFile))
|
|
169
172
|
continue;
|
|
170
173
|
removedLines++;
|
|
171
174
|
if (!curAdded && !curDeleted)
|
|
172
175
|
filesModified.add(curFile);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
176
|
+
if (isCode(curFile)) {
|
|
177
|
+
const body = raw.slice(1);
|
|
178
|
+
const d = declOf(body);
|
|
179
|
+
if (d)
|
|
180
|
+
declsFor(curFile)?.removed.set(d.name, d);
|
|
181
|
+
const imp = importOf(body);
|
|
182
|
+
if (imp && !imp.startsWith("."))
|
|
183
|
+
removedImports.add(imp);
|
|
184
|
+
}
|
|
180
185
|
}
|
|
181
186
|
}
|
|
182
187
|
// per-file symbol classification (added/removed/changed within the same file)
|
package/dist/extractors/git.js
CHANGED
|
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
import { MEMLOG_FORMAT } from "../core/memorylog.js";
|
|
10
10
|
import { hunchAttributesAreSafe, hunchTreeAttributesAreSafe, safeOverlayTree } from "../core/overlaySafety.js";
|
|
11
11
|
import { createRepoFileReader } from "../core/safeRepoFile.js";
|
|
12
|
+
import { initiatorChildEnv } from "../synthesis/initiator.js";
|
|
12
13
|
// `git` exports these repository-local variables to hooks. They outrank cwd/-C,
|
|
13
14
|
// so carrying them from the code repository into a command for the memory
|
|
14
15
|
// overlay can target the wrong index/object store. This is the documented set
|
|
@@ -21,7 +22,7 @@ const LOCAL_GIT_ENV_VARS = [
|
|
|
21
22
|
"GIT_INTERNAL_SUPER_PREFIX", "GIT_SHALLOW_FILE", "GIT_COMMON_DIR",
|
|
22
23
|
];
|
|
23
24
|
export function foreignRepoEnv(source) {
|
|
24
|
-
const env =
|
|
25
|
+
const env = initiatorChildEnv(source);
|
|
25
26
|
for (const key of LOCAL_GIT_ENV_VARS)
|
|
26
27
|
delete env[key];
|
|
27
28
|
for (const key of Object.keys(env)) {
|
|
@@ -58,6 +59,7 @@ function git(args, cwd, maxBuffer = 64 * 1024 * 1024) {
|
|
|
58
59
|
// stdio: capture stdout, silence stderr (so "no commits yet" etc. don't leak).
|
|
59
60
|
return execFileSync("git", args, {
|
|
60
61
|
cwd, encoding: "utf8", maxBuffer,
|
|
62
|
+
env: initiatorChildEnv(),
|
|
61
63
|
stdio: ["ignore", "pipe", "ignore"],
|
|
62
64
|
}).trim();
|
|
63
65
|
}
|
|
@@ -748,6 +750,10 @@ export function headFileContent(root, rel) {
|
|
|
748
750
|
encoding: "utf8",
|
|
749
751
|
env: foreignRepoEnv(process.env),
|
|
750
752
|
maxBuffer: 16 * 1024 * 1024,
|
|
753
|
+
// An untracked/absent-at-HEAD path is an expected, silently-handled case
|
|
754
|
+
// (falls through to the catch below) — don't let git's "fatal: path ...
|
|
755
|
+
// does not exist in 'HEAD'" leak onto the caller's stderr for it.
|
|
756
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
751
757
|
});
|
|
752
758
|
}
|
|
753
759
|
catch {
|
|
@@ -281,4 +281,12 @@ export function languageFor(file) {
|
|
|
281
281
|
}
|
|
282
282
|
return null;
|
|
283
283
|
}
|
|
284
|
+
/** Prose formats worth drafting a decision from even though no LanguageSpec parses
|
|
285
|
+
* them — no grammar, no symbols/edges, just eligible input to synthesis (issue #12). */
|
|
286
|
+
export const PROSE_EXTENSIONS = [".md"];
|
|
287
|
+
/** Broader than languageFor: "is this worth reasoning about" (synthesis input)
|
|
288
|
+
* rather than "can tree-sitter parse this" (symbol/dependency extraction). */
|
|
289
|
+
export function isSubstantive(file) {
|
|
290
|
+
return languageFor(file) !== null || PROSE_EXTENSIONS.some((ext) => file.endsWith(ext));
|
|
291
|
+
}
|
|
284
292
|
//# sourceMappingURL=languages.js.map
|