@copperbox/why 1.0.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 +168 -0
- package/dist/anchor.d.ts +112 -0
- package/dist/anchor.js +697 -0
- package/dist/anchors.d.ts +101 -0
- package/dist/anchors.js +223 -0
- package/dist/audit.d.ts +120 -0
- package/dist/audit.js +483 -0
- package/dist/blame.d.ts +91 -0
- package/dist/blame.js +294 -0
- package/dist/bundle.d.ts +78 -0
- package/dist/bundle.js +188 -0
- package/dist/capture.d.ts +76 -0
- package/dist/capture.js +462 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +641 -0
- package/dist/dig-state.d.ts +46 -0
- package/dist/dig-state.js +146 -0
- package/dist/dig.d.ts +125 -0
- package/dist/dig.js +380 -0
- package/dist/discover.d.ts +11 -0
- package/dist/discover.js +47 -0
- package/dist/doctor.d.ts +135 -0
- package/dist/doctor.js +263 -0
- package/dist/evidence.d.ts +73 -0
- package/dist/evidence.js +425 -0
- package/dist/export.d.ts +67 -0
- package/dist/export.js +106 -0
- package/dist/find-symbol.d.ts +19 -0
- package/dist/find-symbol.js +267 -0
- package/dist/git.d.ts +17 -0
- package/dist/git.js +51 -0
- package/dist/init.d.ts +24 -0
- package/dist/init.js +100 -0
- package/dist/lint.d.ts +40 -0
- package/dist/lint.js +161 -0
- package/dist/serve-assets.d.ts +10 -0
- package/dist/serve-assets.js +55 -0
- package/dist/serve.d.ts +53 -0
- package/dist/serve.js +226 -0
- package/dist/trace-range.d.ts +22 -0
- package/dist/trace-range.js +216 -0
- package/package.json +44 -0
- package/ui/app.js +323 -0
- package/ui/graph.js +164 -0
- package/ui/highlight.js +202 -0
- package/ui/story-panel.d.ts +9 -0
- package/ui/story-panel.js +125 -0
- package/ui/style.css +229 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Anchor, WhyBundle, WhyConcept } from "./bundle.js";
|
|
2
|
+
export interface LineRange {
|
|
3
|
+
start: number;
|
|
4
|
+
end: number;
|
|
5
|
+
}
|
|
6
|
+
/** A code span to look up: a whole file, or a 1-based inclusive line range. */
|
|
7
|
+
export interface SpanQuery {
|
|
8
|
+
path: string;
|
|
9
|
+
lines?: LineRange;
|
|
10
|
+
}
|
|
11
|
+
/** Parse an anchor's `lines` value (`"41-58"` or `"31"`). */
|
|
12
|
+
export declare function parseLineRange(lines: string): LineRange | undefined;
|
|
13
|
+
/** One anchor as indexed: where it came from, plus its parsed span. */
|
|
14
|
+
export interface IndexEntry {
|
|
15
|
+
conceptId: string;
|
|
16
|
+
/** Position in the concept's `why.anchors` list — names the entry for rewrites. */
|
|
17
|
+
anchorIndex: number;
|
|
18
|
+
/** The anchor exactly as written in frontmatter. */
|
|
19
|
+
anchor: Anchor;
|
|
20
|
+
/** Parsed `lines`; absent for whole-file anchors and unparseable spans. */
|
|
21
|
+
range?: LineRange;
|
|
22
|
+
/** True only when `as_of` was confirmed to name the HEAD the index saw. */
|
|
23
|
+
matchesHead: boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The per-path buckets. Unparseable `lines` values get their own bucket: such
|
|
27
|
+
* an anchor cannot verifiably cover any line span (matching it would risk a
|
|
28
|
+
* silently-wrong answer), but a whole-file query still names it — the same
|
|
29
|
+
* semantics `why blame` shipped with in Phase 1.
|
|
30
|
+
*/
|
|
31
|
+
interface PathBucket {
|
|
32
|
+
/** Live anchors with no `lines` — whole-file claims, covering every span. */
|
|
33
|
+
wholeFile: IndexEntry[];
|
|
34
|
+
/** Live ranged anchors, ordered by range start. */
|
|
35
|
+
intervals: IndexEntry[];
|
|
36
|
+
/** Live anchors whose `lines` value does not parse. */
|
|
37
|
+
unparseable: IndexEntry[];
|
|
38
|
+
/** `state: lost` anchors — last-known locations, not live claims. */
|
|
39
|
+
lost: IndexEntry[];
|
|
40
|
+
}
|
|
41
|
+
export interface AnchorIndex {
|
|
42
|
+
/** Repo HEAD the index was built against; absent outside a git repo. */
|
|
43
|
+
head?: string;
|
|
44
|
+
paths: Map<string, PathBucket>;
|
|
45
|
+
}
|
|
46
|
+
/** One lookup result: which concept claims the span, and on what evidence. */
|
|
47
|
+
export interface AnchorHit {
|
|
48
|
+
conceptId: string;
|
|
49
|
+
anchorIndex: number;
|
|
50
|
+
anchor: Anchor;
|
|
51
|
+
/**
|
|
52
|
+
* True unless `as_of` names the HEAD the index was built against. A missing
|
|
53
|
+
* `as_of` or an unknown HEAD is stale too: unverifiable is never fresh.
|
|
54
|
+
*/
|
|
55
|
+
stale: boolean;
|
|
56
|
+
}
|
|
57
|
+
/** HEAD of the git repo enclosing `dir`; undefined outside one (or before any commit). */
|
|
58
|
+
export declare function resolveHead(dir: string): string | undefined;
|
|
59
|
+
/** Build the span→concept index from every concept's `why.anchors`. */
|
|
60
|
+
export declare function buildAnchorIndex(bundle: WhyBundle, head?: string): AnchorIndex;
|
|
61
|
+
/**
|
|
62
|
+
* Resolve an indexed concept id back to its concept. The cache key ties an
|
|
63
|
+
* index to exact bundle contents, so a miss means the caller paired an index
|
|
64
|
+
* with some other bundle.
|
|
65
|
+
*/
|
|
66
|
+
export declare function indexedConcept(bundle: WhyBundle, id: string): WhyConcept;
|
|
67
|
+
export interface LookupOptions {
|
|
68
|
+
/** Also return `lost` anchors whose last-known span covers the query. */
|
|
69
|
+
includeLost?: boolean;
|
|
70
|
+
}
|
|
71
|
+
/** Every anchor covering `query`: live ones by default, lost ones on request. */
|
|
72
|
+
export declare function lookupAnchors(index: AnchorIndex, query: SpanQuery, options?: LookupOptions): AnchorHit[];
|
|
73
|
+
export declare const CACHE_DIRNAME = ".cache";
|
|
74
|
+
/**
|
|
75
|
+
* Content hash of the bundle: every `.md` file under the root (dot
|
|
76
|
+
* directories excluded — `.cache` must not invalidate itself), by path and
|
|
77
|
+
* bytes, so any concept edit, addition, removal, or rename changes the key.
|
|
78
|
+
*/
|
|
79
|
+
export declare function bundleContentHash(root: string): Promise<string>;
|
|
80
|
+
/** Create `dir` as derived state: it ignores itself so no bundle commits it. */
|
|
81
|
+
export declare function ensureSelfIgnoringDir(dir: string): Promise<void>;
|
|
82
|
+
export interface LoadAnchorIndexOptions {
|
|
83
|
+
/** Repo HEAD to key and judge staleness by; default: resolved from the bundle root. */
|
|
84
|
+
head?: string;
|
|
85
|
+
/** Cache directory; default: `<bundle root>/.cache`. */
|
|
86
|
+
cacheDir?: string;
|
|
87
|
+
/** Set false to skip persisting a rebuilt index (read-only callers). */
|
|
88
|
+
write?: boolean;
|
|
89
|
+
}
|
|
90
|
+
export interface LoadedAnchorIndex {
|
|
91
|
+
index: AnchorIndex;
|
|
92
|
+
/** Where the index came from — how tests prove reuse without timing. */
|
|
93
|
+
source: "cache" | "built";
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The cached anchor index for a bundle: reused only when both the bundle
|
|
97
|
+
* contents and the repo HEAD still match the key it was built under,
|
|
98
|
+
* rebuilt (and rewritten) otherwise.
|
|
99
|
+
*/
|
|
100
|
+
export declare function loadAnchorIndex(bundle: WhyBundle, options?: LoadAnchorIndexOptions): Promise<LoadedAnchorIndex>;
|
|
101
|
+
export {};
|
package/dist/anchors.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Anchor index (DESIGN.md §4, §7 step 1): the shared span→concept lookup
|
|
2
|
+
// layer `blame`, `anchor`, and `doctor` sit on. Built in memory from every
|
|
3
|
+
// concept's `why.anchors`, cached on disk under `<bundle>/.cache/` keyed by
|
|
4
|
+
// (bundle content hash, repo HEAD) — a key mismatch always rebuilds, so the
|
|
5
|
+
// cache is fresh or discarded, never silently stale. Lookup distinguishes
|
|
6
|
+
// live from `lost` anchors and reports per hit whether `as_of` still names
|
|
7
|
+
// HEAD; re-resolving stale anchors belongs to `why anchor`, not this layer.
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
/** Parse an anchor's `lines` value (`"41-58"` or `"31"`). */
|
|
13
|
+
export function parseLineRange(lines) {
|
|
14
|
+
const match = /^(\d+)\s*-\s*(\d+)$|^(\d+)$/.exec(lines.trim());
|
|
15
|
+
if (!match)
|
|
16
|
+
return undefined;
|
|
17
|
+
const start = Number(match[1] ?? match[3]);
|
|
18
|
+
const end = Number(match[2] ?? match[3]);
|
|
19
|
+
return start >= 1 && end >= start ? { start, end } : undefined;
|
|
20
|
+
}
|
|
21
|
+
/** Whether `as_of` names `head` exactly or as an abbreviated (≥4 char) prefix. */
|
|
22
|
+
function asOfMatchesHead(asOf, head) {
|
|
23
|
+
if (asOf === undefined || head === undefined)
|
|
24
|
+
return false;
|
|
25
|
+
const abbrev = asOf.toLowerCase();
|
|
26
|
+
return abbrev.length >= 4 && head.toLowerCase().startsWith(abbrev);
|
|
27
|
+
}
|
|
28
|
+
/** HEAD of the git repo enclosing `dir`; undefined outside one (or before any commit). */
|
|
29
|
+
export function resolveHead(dir) {
|
|
30
|
+
const result = spawnSync("git", ["rev-parse", "HEAD"], { cwd: dir, encoding: "utf8" });
|
|
31
|
+
const head = result.status === 0 ? result.stdout.trim() : "";
|
|
32
|
+
return head === "" ? undefined : head;
|
|
33
|
+
}
|
|
34
|
+
function emptyBucket() {
|
|
35
|
+
return { wholeFile: [], intervals: [], unparseable: [], lost: [] };
|
|
36
|
+
}
|
|
37
|
+
/** Build the span→concept index from every concept's `why.anchors`. */
|
|
38
|
+
export function buildAnchorIndex(bundle, head) {
|
|
39
|
+
const paths = new Map();
|
|
40
|
+
for (const concept of bundle.concepts.values()) {
|
|
41
|
+
concept.why.anchors.forEach((anchor, anchorIndex) => {
|
|
42
|
+
let bucket = paths.get(anchor.path);
|
|
43
|
+
if (!bucket) {
|
|
44
|
+
bucket = emptyBucket();
|
|
45
|
+
paths.set(anchor.path, bucket);
|
|
46
|
+
}
|
|
47
|
+
const range = anchor.lines === undefined ? undefined : parseLineRange(anchor.lines);
|
|
48
|
+
const entry = {
|
|
49
|
+
conceptId: concept.id,
|
|
50
|
+
anchorIndex,
|
|
51
|
+
anchor,
|
|
52
|
+
matchesHead: asOfMatchesHead(anchor.as_of, head),
|
|
53
|
+
};
|
|
54
|
+
if (range)
|
|
55
|
+
entry.range = range;
|
|
56
|
+
if (anchor.state === "lost")
|
|
57
|
+
bucket.lost.push(entry);
|
|
58
|
+
else if (anchor.lines === undefined)
|
|
59
|
+
bucket.wholeFile.push(entry);
|
|
60
|
+
else if (range)
|
|
61
|
+
bucket.intervals.push(entry);
|
|
62
|
+
else
|
|
63
|
+
bucket.unparseable.push(entry);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
for (const bucket of paths.values()) {
|
|
67
|
+
bucket.intervals.sort((a, b) => a.range.start - b.range.start || a.range.end - b.range.end);
|
|
68
|
+
}
|
|
69
|
+
const index = { paths };
|
|
70
|
+
if (head !== undefined)
|
|
71
|
+
index.head = head;
|
|
72
|
+
return index;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Resolve an indexed concept id back to its concept. The cache key ties an
|
|
76
|
+
* index to exact bundle contents, so a miss means the caller paired an index
|
|
77
|
+
* with some other bundle.
|
|
78
|
+
*/
|
|
79
|
+
export function indexedConcept(bundle, id) {
|
|
80
|
+
const concept = bundle.concepts.get(id);
|
|
81
|
+
if (!concept) {
|
|
82
|
+
throw new Error(`anchor index names unknown concept "${id}" — it was not built from this bundle`);
|
|
83
|
+
}
|
|
84
|
+
return concept;
|
|
85
|
+
}
|
|
86
|
+
/** Whether an indexed entry's span covers the queried span. */
|
|
87
|
+
function covers(entry, query) {
|
|
88
|
+
if (entry.anchor.lines === undefined)
|
|
89
|
+
return true; // whole-file claim
|
|
90
|
+
if (!query.lines)
|
|
91
|
+
return true; // whole-file query names every anchor on the path
|
|
92
|
+
if (!entry.range)
|
|
93
|
+
return false; // unparseable span cannot verifiably cover lines
|
|
94
|
+
return entry.range.start <= query.lines.end && query.lines.start <= entry.range.end;
|
|
95
|
+
}
|
|
96
|
+
/** Every anchor covering `query`: live ones by default, lost ones on request. */
|
|
97
|
+
export function lookupAnchors(index, query, options = {}) {
|
|
98
|
+
const bucket = index.paths.get(query.path);
|
|
99
|
+
if (!bucket)
|
|
100
|
+
return [];
|
|
101
|
+
const matched = [...bucket.wholeFile];
|
|
102
|
+
if (query.lines) {
|
|
103
|
+
for (const entry of bucket.intervals) {
|
|
104
|
+
if (entry.range.start > query.lines.end)
|
|
105
|
+
break; // ordered by start
|
|
106
|
+
if (covers(entry, query))
|
|
107
|
+
matched.push(entry);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
matched.push(...bucket.intervals, ...bucket.unparseable);
|
|
112
|
+
}
|
|
113
|
+
if (options.includeLost === true) {
|
|
114
|
+
matched.push(...bucket.lost.filter((entry) => covers(entry, query)));
|
|
115
|
+
}
|
|
116
|
+
return matched.map((entry) => ({
|
|
117
|
+
conceptId: entry.conceptId,
|
|
118
|
+
anchorIndex: entry.anchorIndex,
|
|
119
|
+
anchor: entry.anchor,
|
|
120
|
+
stale: !entry.matchesHead,
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
// --- Disk cache ----------------------------------------------------------------
|
|
124
|
+
export const CACHE_DIRNAME = ".cache";
|
|
125
|
+
const INDEX_CACHE_FILENAME = "anchor-index.json";
|
|
126
|
+
const CACHE_VERSION = 1;
|
|
127
|
+
/**
|
|
128
|
+
* Content hash of the bundle: every `.md` file under the root (dot
|
|
129
|
+
* directories excluded — `.cache` must not invalidate itself), by path and
|
|
130
|
+
* bytes, so any concept edit, addition, removal, or rename changes the key.
|
|
131
|
+
*/
|
|
132
|
+
export async function bundleContentHash(root) {
|
|
133
|
+
const files = [];
|
|
134
|
+
await listMarkdown(root, "", files);
|
|
135
|
+
files.sort();
|
|
136
|
+
const hash = createHash("sha256");
|
|
137
|
+
for (const rel of files) {
|
|
138
|
+
hash.update(rel);
|
|
139
|
+
hash.update("\0");
|
|
140
|
+
hash.update(await readFile(join(root, rel)));
|
|
141
|
+
hash.update("\0");
|
|
142
|
+
}
|
|
143
|
+
return hash.digest("hex");
|
|
144
|
+
}
|
|
145
|
+
async function listMarkdown(dir, prefix, out) {
|
|
146
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
147
|
+
if (entry.name.startsWith("."))
|
|
148
|
+
continue;
|
|
149
|
+
const rel = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
150
|
+
if (entry.isDirectory())
|
|
151
|
+
await listMarkdown(join(dir, entry.name), rel, out);
|
|
152
|
+
else if (entry.name.endsWith(".md"))
|
|
153
|
+
out.push(rel);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function serializeIndex(index, bundleHash) {
|
|
157
|
+
return {
|
|
158
|
+
version: CACHE_VERSION,
|
|
159
|
+
bundleHash,
|
|
160
|
+
head: index.head ?? null,
|
|
161
|
+
paths: [...index.paths.entries()].map(([path, bucket]) => ({ path, ...bucket })),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function deserializeIndex(cache) {
|
|
165
|
+
const paths = new Map();
|
|
166
|
+
for (const { path, wholeFile, intervals, unparseable, lost } of cache.paths) {
|
|
167
|
+
paths.set(path, { wholeFile, intervals, unparseable, lost });
|
|
168
|
+
}
|
|
169
|
+
const index = { paths };
|
|
170
|
+
if (cache.head !== null)
|
|
171
|
+
index.head = cache.head;
|
|
172
|
+
return index;
|
|
173
|
+
}
|
|
174
|
+
async function readCache(cachePath, bundleHash, head) {
|
|
175
|
+
let cache;
|
|
176
|
+
try {
|
|
177
|
+
cache = JSON.parse(await readFile(cachePath, "utf8"));
|
|
178
|
+
if (cache.version !== CACHE_VERSION ||
|
|
179
|
+
cache.bundleHash !== bundleHash ||
|
|
180
|
+
(cache.head ?? undefined) !== head ||
|
|
181
|
+
!Array.isArray(cache.paths)) {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
return deserializeIndex(cache);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return undefined; // absent, unreadable, or corrupt: rebuild, never trust
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** Create `dir` as derived state: it ignores itself so no bundle commits it. */
|
|
191
|
+
export async function ensureSelfIgnoringDir(dir) {
|
|
192
|
+
await mkdir(dir, { recursive: true });
|
|
193
|
+
try {
|
|
194
|
+
await writeFile(join(dir, ".gitignore"), "*\n", { flag: "wx" });
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
if (e.code !== "EEXIST")
|
|
198
|
+
throw e;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async function writeCache(cacheDir, cachePath, cache) {
|
|
202
|
+
await ensureSelfIgnoringDir(cacheDir);
|
|
203
|
+
await writeFile(cachePath, `${JSON.stringify(cache)}\n`, "utf8");
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The cached anchor index for a bundle: reused only when both the bundle
|
|
207
|
+
* contents and the repo HEAD still match the key it was built under,
|
|
208
|
+
* rebuilt (and rewritten) otherwise.
|
|
209
|
+
*/
|
|
210
|
+
export async function loadAnchorIndex(bundle, options = {}) {
|
|
211
|
+
const head = options.head ?? resolveHead(bundle.root);
|
|
212
|
+
const cacheDir = options.cacheDir ?? join(bundle.root, CACHE_DIRNAME);
|
|
213
|
+
const cachePath = join(cacheDir, INDEX_CACHE_FILENAME);
|
|
214
|
+
const bundleHash = await bundleContentHash(bundle.root);
|
|
215
|
+
const cached = await readCache(cachePath, bundleHash, head);
|
|
216
|
+
if (cached)
|
|
217
|
+
return { index: cached, source: "cache" };
|
|
218
|
+
const index = buildAnchorIndex(bundle, head);
|
|
219
|
+
if (options.write !== false) {
|
|
220
|
+
await writeCache(cacheDir, cachePath, serializeIndex(index, bundleHash));
|
|
221
|
+
}
|
|
222
|
+
return { index, source: "built" };
|
|
223
|
+
}
|
package/dist/audit.d.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { type WhyBundle } from "./bundle.js";
|
|
2
|
+
/** An audit run that must stop cleanly (exit 1), not crash. */
|
|
3
|
+
export declare class AuditError extends Error {
|
|
4
|
+
}
|
|
5
|
+
/** How long one `verify.check` command may run before it counts as an error. */
|
|
6
|
+
export declare const DEFAULT_CHECK_TIMEOUT_MS = 30000;
|
|
7
|
+
/** Chars of captured check output kept — clipped with a marker, never silently. */
|
|
8
|
+
export declare const CHECK_OUTPUT_LIMIT = 4000;
|
|
9
|
+
/**
|
|
10
|
+
* `error` means the check could not be evaluated (timeout, spawn failure) —
|
|
11
|
+
* that is a problem to report, but it is not evidence the constraint is
|
|
12
|
+
* false, so it never flips anything.
|
|
13
|
+
*/
|
|
14
|
+
export type CheckOutcome = "passed" | "failed" | "error";
|
|
15
|
+
export interface CheckResult {
|
|
16
|
+
concept: string;
|
|
17
|
+
command: string;
|
|
18
|
+
outcome: CheckOutcome;
|
|
19
|
+
/** null when the command produced no exit code (timeout, spawn failure). */
|
|
20
|
+
exitCode: number | null;
|
|
21
|
+
/** Combined stdout+stderr, clipped to CHECK_OUTPUT_LIMIT with a marker. */
|
|
22
|
+
output: string;
|
|
23
|
+
/** Why the outcome is `error`. */
|
|
24
|
+
detail?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare const ASK_ANSWERS: readonly ["still-true", "no-longer-true", "unknown"];
|
|
27
|
+
export type AskAnswer = (typeof ASK_ANSWERS)[number];
|
|
28
|
+
export interface AskResult {
|
|
29
|
+
concept: string;
|
|
30
|
+
ask: string;
|
|
31
|
+
/** From the `--answers` file; `unknown` when unanswered. */
|
|
32
|
+
answer: AskAnswer;
|
|
33
|
+
}
|
|
34
|
+
export interface ReviewByDueItem {
|
|
35
|
+
concept: string;
|
|
36
|
+
review_by: string;
|
|
37
|
+
}
|
|
38
|
+
export interface UnverifiableItem {
|
|
39
|
+
concept: string;
|
|
40
|
+
reason: string;
|
|
41
|
+
}
|
|
42
|
+
export interface BlastEntry {
|
|
43
|
+
/** Concept id of the still-active downstream decision. */
|
|
44
|
+
decision: string;
|
|
45
|
+
/** The question concept covering the pair — freshly written or pre-existing. */
|
|
46
|
+
question: string;
|
|
47
|
+
/** false when an equivalent open question already existed (deduped). */
|
|
48
|
+
written: boolean;
|
|
49
|
+
}
|
|
50
|
+
export interface ExpiredConstraint {
|
|
51
|
+
concept: string;
|
|
52
|
+
method: "check" | "ask";
|
|
53
|
+
expired_on: string;
|
|
54
|
+
/** One line of what falsified it (the full evidence lands in `# Still true?`). */
|
|
55
|
+
detail: string;
|
|
56
|
+
blastRadius: BlastEntry[];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A constraint that was already `expired` before this run and still has
|
|
60
|
+
* `active` decisions downstream — candidate scar tissue. Report-only: the
|
|
61
|
+
* flip (and its question filing) happened when it expired; re-flagging it
|
|
62
|
+
* every run would be noise, but the candidacy must stay visible.
|
|
63
|
+
*/
|
|
64
|
+
export interface AlreadyExpiredItem {
|
|
65
|
+
concept: string;
|
|
66
|
+
expired_on: string | null;
|
|
67
|
+
decisions: string[];
|
|
68
|
+
}
|
|
69
|
+
/** The `--json` shape. Keys are a stable, tested surface. */
|
|
70
|
+
export interface AuditReport {
|
|
71
|
+
root: string;
|
|
72
|
+
activeConstraints: number;
|
|
73
|
+
checks: CheckResult[];
|
|
74
|
+
asks: AskResult[];
|
|
75
|
+
reviewByPastDue: ReviewByDueItem[];
|
|
76
|
+
unverifiable: UnverifiableItem[];
|
|
77
|
+
/** Constraints flipped by this run — the exit-1 condition. */
|
|
78
|
+
expired: ExpiredConstraint[];
|
|
79
|
+
alreadyExpired: AlreadyExpiredItem[];
|
|
80
|
+
/** Bundle-relative paths of the question concepts written. */
|
|
81
|
+
questionsWritten: string[];
|
|
82
|
+
/** Absolute path of the questionnaire written, when --questions-out was passed. */
|
|
83
|
+
questionnaire: string | null;
|
|
84
|
+
}
|
|
85
|
+
export interface AnswerEntry {
|
|
86
|
+
answer: AskAnswer;
|
|
87
|
+
evidence: string;
|
|
88
|
+
}
|
|
89
|
+
export interface AuditOptions {
|
|
90
|
+
/** Parsed `--answers` content, keyed by concept id. */
|
|
91
|
+
answers?: Map<string, AnswerEntry>;
|
|
92
|
+
/** Absolute path to write the `method: ask` questionnaire to. */
|
|
93
|
+
questionsOut?: string;
|
|
94
|
+
/** "Today" for expired_on and review_by; injectable so tests are deterministic. */
|
|
95
|
+
now?: Date;
|
|
96
|
+
timeoutMs?: number;
|
|
97
|
+
}
|
|
98
|
+
export declare const EVIDENCE_PLACEHOLDER = "(replace this line with the evidence for your answer)";
|
|
99
|
+
/** The agent-facing export of `method: ask` items; round-trips via `--answers`. */
|
|
100
|
+
export declare function renderQuestionnaire(items: AskResult[]): string;
|
|
101
|
+
/**
|
|
102
|
+
* Parse a filled-in questionnaire: `## <concept-id>` starts an item, the first
|
|
103
|
+
* `answer:` line inside it is the verdict, and everything after that line
|
|
104
|
+
* (until the next item) is the evidence. Strict where it matters: an
|
|
105
|
+
* unrecognized answer or a `no-longer-true` without evidence is an error —
|
|
106
|
+
* the flip writes that evidence into `# Still true?`, so it cannot be empty.
|
|
107
|
+
*/
|
|
108
|
+
export declare function parseAnswers(text: string): Map<string, AnswerEntry>;
|
|
109
|
+
/**
|
|
110
|
+
* Walk `# Because of` edges backwards from a constraint (DESIGN.md §5):
|
|
111
|
+
* concepts linking to it from a `# Because of` section exist because of it,
|
|
112
|
+
* transitively. Returns every `active` decision reached, sorted.
|
|
113
|
+
*/
|
|
114
|
+
export declare function blastRadius(bundle: WhyBundle, constraintId: string): string[];
|
|
115
|
+
/**
|
|
116
|
+
* The full audit: sweep, verify, flip, walk, file. Returns the report; exit
|
|
117
|
+
* semantics (1 when anything newly expired) belong to the CLI.
|
|
118
|
+
*/
|
|
119
|
+
export declare function auditBundle(bundle: WhyBundle, options?: AuditOptions): Promise<AuditReport>;
|
|
120
|
+
export declare function renderAuditReport(report: AuditReport): string[];
|