unknown-knowledge 2.1.0 → 3.0.0-rc.1

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.
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * node payload/engine/resolve.js <query terms...> [--json] [--root <dir>]
7
7
  * node payload/engine/resolve.js --paths <file1,file2> [--json] [--root <dir>]
8
+ * node payload/engine/resolve.js --path <file> [--path <file>...] [--json] [--root <dir>]
8
9
  * node payload/engine/resolve.js --doc <document> [--json] [--root <dir>]
9
10
  *
10
11
  * ONE ENTRY POINT, THREE INPUT SHAPES (UCS-1156). A query, a set of repo paths,
@@ -86,6 +87,14 @@
86
87
  * immediately around a hit, and depth 2 is most of the store
87
88
  * arriving unranked
88
89
  *
90
+ * Incoming supersession (UCS-1226) adds a separate `superseded-by` array to
91
+ * leaf results, including document gather and scope exclusions. References
92
+ * carry id/notation/heading/file, stage/time/downranked/demotions, and applies
93
+ * (declared jurisdictions, empty means universal). No content, score, trust
94
+ * verdict, or nested edges: callers compare scope, preflight targets, and read
95
+ * sources. All direct successors are sorted by accession, never by recency.
96
+ * The inverse is rebuilt from relates.supersedes at load; no reciprocal authoring.
97
+ *
89
98
  * Knowledge entry points now join STRUCTURALLY as well as textually: a leaf
90
99
  * that declares a concept surfaces under it whether or not any term text
91
100
  * matches, so knowledge stops depending on two authors choosing the same words.
@@ -164,7 +173,7 @@
164
173
  * localized enough to act on. The stopword list is pinned and shipped in the
165
174
  * engine (lib/decomposition.js), never configurable per run.
166
175
  *
167
- * --paths mode — reverse lookup over BOTH pointer families: "which concepts
176
+ * Paths mode (--path or legacy --paths) — reverse lookup over BOTH pointer families: "which concepts
168
177
  * point at these files, and which leaves govern them" (UCS-1151). The join runs
169
178
  * over concept source-of-truth pointers and over leaf `paths` declarations, so
170
179
  * a diff-shaped input surfaces the knowledge that governs the files before an
@@ -173,17 +182,18 @@
173
182
  * nested under a FOLDER pointer (§3.1). Folder-ness is read from the
174
183
  * filesystem, not from the name — `src/api.v2` is a directory whose extname is
175
184
  * ".v2" — and from the name only when the pointer is gone, since a diff names
176
- * deleted paths; see folderPointerTest. Paths are normalized with path.posix
177
- * semantics (dots resolved, separators collapsed, backslashes converted,
178
- * absolute paths relativized against the store root) so attribution survives
179
- * the forms real tooling emits. An entry naming the repo root is a usage
185
+ * deleted paths; see folderPointerTest. Paths use path.posix semantics (dots
186
+ * resolved, separators collapsed, absolute paths relativized against the store
187
+ * root). Legacy --paths trims whitespace and converts backslashes; --path keeps
188
+ * both literal and takes one complete name per argument, without comma splitting.
189
+ * An entry naming the repo root is a usage
180
190
  * error, never a silently dropped lookup. A lookup, not subset validation (no
181
191
  * D-012 conflict). Paths are deduped and sorted ascending.
182
192
  *
183
193
  * Zero resolution is a NORMAL outcome (PRD §7 — common in month one): exit 0
184
- * with an explicit empty result plus the fallback conduct (search within
185
- * survey-scope.yaml; append a retrieval-miss finding only if the topic
186
- * plausibly should be mapped). Exit codes (PRD §5): 0 = the lookup ran (hits
194
+ * with an explicit empty result plus a pointer to the canonical recovery
195
+ * protocol (preflight, catalog recovery, then survey-scoped fallback).
196
+ * Exit codes (PRD §5): 0 = the lookup ran (hits
187
197
  * or none), 2 = usage/engine failure — a lookup that never ran is a failure,
188
198
  * never a silent empty result. The resolver emits no findings, so it never
189
199
  * exits 1; gating on store health is preflight's job. Store health is still
@@ -225,6 +235,7 @@ import { loadSuppressions } from '../lib/suppressions.js';
225
235
 
226
236
  export const USAGE = `usage: node payload/engine/resolve.js <query terms...> [--json] [--root <dir>] [--today <YYYY-MM-DD>]
227
237
  node payload/engine/resolve.js --paths <file1,file2> [--json] [--root <dir>] [--today <YYYY-MM-DD>]
238
+ node payload/engine/resolve.js --path <file> [--path <file>...] [--json] [--root <dir>] [--today <YYYY-MM-DD>]
228
239
  node payload/engine/resolve.js --doc <document> [--json] [--root <dir>] [--today <YYYY-MM-DD>]`;
229
240
 
230
241
  // The concept ladder and the draft downrank moved to lib/scoring.js (UCS-1152)
@@ -415,10 +426,8 @@ function confusables(model, record) {
415
426
  * OUTGOING edges only. The ticket says the resolver expands over relates edges
416
427
  * FROM a hit, and outgoing is what this leaf's author asserted: a leaf declares
417
428
  * what IT depends on, what IT contradicts. An incoming edge is somebody else's
418
- * claim about this leaf, which is a genuinely useful thing to see and a
419
- * different question it belongs to whatever surface presents "what cites
420
- * this", where it can be labeled as such rather than blended into the leaf's
421
- * own assertions.
429
+ * claim about this leaf. Incoming supersession is published separately as
430
+ * `superseded-by` (UCS-1226), never blended into the leaf's own assertions.
422
431
  *
423
432
  * Neighbors resolve through `leafIdentityOf`, the one lookup every surface
424
433
  * asks — so an edge citing a leaf by its retired notation reaches nothing here
@@ -473,7 +482,7 @@ function relatesNeighborhood(model, record) {
473
482
  * are exactly the ones whose whole contract is that they are STABLE keys that
474
483
  * may be null.
475
484
  */
476
- function publishLeaf(model, entry, today) {
485
+ function leafMetadata(entry, today) {
477
486
  const { file, record: leaf } = entry;
478
487
  const stage = leafStage(leaf);
479
488
  const provenance = leaf.provenance;
@@ -564,10 +573,19 @@ function publishLeaf(model, entry, today) {
564
573
  // reading one answer.
565
574
  time,
566
575
  file,
567
- // The one-hop structural neighborhood (UCS-1151) — every leaf the resolver
568
- // publishes carries it, so "any hit carries its relates neighborhood" is
569
- // true by construction rather than by remembering to attach it per mode.
570
- [RELATES_FIELD]: relatesNeighborhood(model, leaf),
576
+ };
577
+ }
578
+
579
+ /** One shared projection; successors are navigation, never recursively expanded. */
580
+ function publishLeaf(model, entry, today) {
581
+ return {
582
+ ...leafMetadata(entry, today),
583
+ [RELATES_FIELD]: relatesNeighborhood(model, entry.record),
584
+ 'superseded-by': (model.supersedingLeaves.get(entry.identity) ?? []).map((id) => {
585
+ const successor = model.leaves.get(id);
586
+ const { excerpt, provenance, ...metadata } = leafMetadata(successor, today);
587
+ return { ...metadata, applies: [...leafJurisdictions(successor.record)].sort(compare) };
588
+ }),
571
589
  };
572
590
  }
573
591
 
@@ -866,6 +884,7 @@ function applyScope(scored, jurisdictions) {
866
884
  file: leaf.file,
867
885
  applies: leaf.applies,
868
886
  asked,
887
+ 'superseded-by': leaf['superseded-by'],
869
888
  reason: `declares applies.jurisdictions [${leaf.applies.join(', ')}] — the query is scoped to [${asked.join(', ')}], which this leaf does not cover (UCS-1152)`,
870
889
  });
871
890
  }
@@ -1028,19 +1047,21 @@ function resolveQuery(model, terms, today) {
1028
1047
  * lookup failed or the store is simply silent on the topic, and those demand
1029
1048
  * different next steps.
1030
1049
  */
1031
- const ZERO_RESOLUTION_CONDUCT = 'zero resolution is a normal outcome (PRD §7): fall back to search within survey-scope.yaml; append a retrieval-miss finding only if this topic plausibly should be mapped (an unmapped area the scope excludes is expected, not a miss)';
1050
+ const ZERO_RESOLUTION_CONDUCT = 'zero resolution is a normal outcome (PRD §7): preflight store health, recover through relevant catalogs, then search unresolved tasks through the survey map bounded by repo-root survey-scope.yaml. Record recovered wording as retrieval-struggle, missing in-scope evidence as retrieval-miss, and excluded topics as expected absence. Follow protocol/AGENTS.md for layout and recovery rules.';
1032
1051
 
1033
1052
  // ---------------------------------------------------------------- paths mode
1034
1053
 
1035
1054
  /**
1036
1055
  * Normalize a path to the repo-root-relative posix form pointers use (§9.1):
1037
- * backslashes become '/', `..`/`.`/`//` resolve away (path.posix semantics),
1056
+ * `..`/`.`/`//` resolve away (path.posix semantics),
1038
1057
  * trailing slashes drop, and absolute paths relativize against `root`.
1058
+ * Legacy --paths also trims whitespace and converts backslashes to '/'. With
1059
+ * --path, those bytes are literal filename data on both sides of the join.
1039
1060
  * Wrong normalization is wrong ATTRIBUTION — `a/b/../c.ts` must hit the file
1040
1061
  * pointer `a/c.ts`, not the folder pointer `a/b`.
1041
1062
  */
1042
- function normPath(root, p) {
1043
- let path = posix.normalize(p.trim().replace(/\\/g, '/'));
1063
+ function normPath(root, p, literal = false) {
1064
+ let path = posix.normalize(literal ? p : p.trim().replace(/\\/g, '/'));
1044
1065
  if (posix.isAbsolute(path)) path = posix.relative(root.replace(/\\/g, '/'), path);
1045
1066
  path = path.replace(/\/+$/, '');
1046
1067
  return path === '.' ? '' : path;
@@ -1088,20 +1109,21 @@ function folderPointerTest(repoRoot) {
1088
1109
  };
1089
1110
  }
1090
1111
 
1091
- function resolvePaths(model, rawPaths, repoRoot, today) {
1112
+ function resolvePaths(model, rawPaths, repoRoot, today, literal = false) {
1113
+ const flag = literal ? '--path' : '--paths';
1092
1114
  // Pointers are repo-root-relative (§9.1), so both sides normalize against
1093
1115
  // the repo root — the KK-08 two-root convention (model.root may be the
1094
1116
  // nested unknown-knowledge/ store dir in a seeded repo).
1095
1117
  // An entry that normalizes away (empty, ".", "src/..") names the repo root,
1096
1118
  // not a path inside it. Dropping it silently would shrink the lookup the
1097
1119
  // caller asked for — a lookup that never ran, wearing a clean exit.
1098
- const rootish = rawPaths.filter((p) => normPath(repoRoot, p) === '');
1120
+ const rootish = rawPaths.filter((p) => normPath(repoRoot, p, literal) === '');
1099
1121
  if (rootish.length) {
1100
- throw new UsageError(`--paths entries ${rootish.map((p) => JSON.stringify(p)).join(', ')} name the repo root, not a path inside it — name the files or directories the change touched`);
1122
+ throw new UsageError(`${flag} entries ${rootish.map((p) => JSON.stringify(p)).join(', ')} name the repo root, not a path inside it — name the files or directories the change touched`);
1101
1123
  }
1102
- const paths = [...new Set(rawPaths.map((p) => normPath(repoRoot, p)))].sort(compare);
1124
+ const paths = [...new Set(rawPaths.map((p) => normPath(repoRoot, p, literal)))].sort(compare);
1103
1125
  if (!paths.length) {
1104
- throw new UsageError('--paths must name at least one path — a lookup that never ran is a failure, never a silent empty result');
1126
+ throw new UsageError(`${flag} must name at least one path — a lookup that never ran is a failure, never a silent empty result`);
1105
1127
  }
1106
1128
  const isFolderPointer = folderPointerTest(repoRoot);
1107
1129
  // Leaf `paths` are the second pointer family (UCS-1151), indexed once for the
@@ -1123,7 +1145,7 @@ function resolvePaths(model, rawPaths, repoRoot, today) {
1123
1145
  * the validator is where the author is told to fix it.
1124
1146
  */
1125
1147
  const governs = (pointer, path) => {
1126
- const p = normPath(repoRoot, pointer);
1148
+ const p = normPath(repoRoot, pointer, literal);
1127
1149
  if (p === '') return false;
1128
1150
  return path === p || (isFolderPointer(p) && path.startsWith(`${p}/`));
1129
1151
  };
@@ -1332,6 +1354,7 @@ function renderDoc(payload) {
1332
1354
  // applicability the reader has to act on, not a detail of the hit.
1333
1355
  if (g['scope-mismatch']) lines.push(` scope-mismatch: ${g['scope-mismatch']}`);
1334
1356
  for (const d of g.demotions) lines.push(` demoted (${d.reason}): ${d.detail}`);
1357
+ lines.push(...renderSuccessors(g, ' '));
1335
1358
  }
1336
1359
  lines.push('');
1337
1360
  }
@@ -1363,15 +1386,19 @@ function parseArgs(argv) {
1363
1386
  const { options, positionals } = parseFlags(argv, {
1364
1387
  boolean: ['json'],
1365
1388
  value: ['root', 'today', 'doc'],
1366
- repeatable: ['paths'],
1367
- // Query terms arrive as bare arguments; --paths is the reverse lookup;
1389
+ repeatable: ['paths', 'path'],
1390
+ // Query terms arrive as bare arguments; --path/--paths are reverse lookup;
1368
1391
  // --doc is the third input shape (UCS-1156).
1369
1392
  positionals: true,
1370
1393
  });
1394
+ if (options.path && options.paths) {
1395
+ throw new UsageError('cannot combine --path and --paths — choose complete paths or legacy comma-separated lists');
1396
+ }
1371
1397
  const opts = {
1372
1398
  json: !!options.json,
1373
1399
  root: options.root ?? process.cwd(),
1374
- paths: options.paths ? options.paths.flatMap((v) => v.split(',')) : null,
1400
+ paths: options.path ?? (options.paths ? options.paths.flatMap((v) => v.split(',')) : null),
1401
+ literalPaths: !!options.path,
1375
1402
  doc: options.doc ?? null,
1376
1403
  terms: positionals,
1377
1404
  // The injected date the time verdicts are measured against (UCS-1150).
@@ -1390,14 +1417,14 @@ function parseArgs(argv) {
1390
1417
  // a query — so it is a usage error rather than a silently-preferred mode.
1391
1418
  const shapes = [
1392
1419
  opts.terms.length ? 'query terms' : null,
1393
- opts.paths ? '--paths' : null,
1420
+ opts.paths ? (opts.literalPaths ? '--path' : '--paths') : null,
1394
1421
  opts.doc ? '--doc' : null,
1395
1422
  ].filter(Boolean);
1396
1423
  if (shapes.length > 1) {
1397
- throw new UsageError(`give exactly one input shape, got ${shapes.join(' and ')} — a query, --paths, or --doc`);
1424
+ throw new UsageError(`give exactly one input shape, got ${shapes.join(' and ')} — a query, --path/--paths, or --doc`);
1398
1425
  }
1399
1426
  if (!shapes.length) {
1400
- throw new UsageError('nothing to resolve — give query terms, --paths, or --doc');
1427
+ throw new UsageError('nothing to resolve — give query terms, --path/--paths, or --doc');
1401
1428
  }
1402
1429
  return opts;
1403
1430
  }
@@ -1430,7 +1457,15 @@ function renderRelates(leaf, indent = ' ') {
1430
1457
  if (!neighbors.length) continue;
1431
1458
  lines.push(`${indent}${kind}: ${neighbors.map((n) => `${n.id ?? n.notation} "${n.heading ?? '?'}"`).join(', ')}`);
1432
1459
  }
1433
- return lines;
1460
+ return [...lines, ...renderSuccessors(leaf, indent)];
1461
+ }
1462
+
1463
+ /** Incoming claims remain navigation: applicability and target preflight are required. */
1464
+ function renderSuccessors(leaf, indent) {
1465
+ return (leaf['superseded-by'] ?? []).map((successor) =>
1466
+ `${indent}superseded-by: ${successor.id} "${successor.heading ?? '?'}" (${successor.file})`
1467
+ + ` [stage: ${successor.stage ?? 'unknown'}; time: ${successor.time.verdict}; jurisdictions: ${successor.applies.join(', ') || 'universal'}]`
1468
+ + ' — verify applicability, preflight this target, and read its source before selecting an answer');
1434
1469
  }
1435
1470
 
1436
1471
  /**
@@ -1508,6 +1543,7 @@ function renderExclusions(payload, lines) {
1508
1543
  for (const x of payload.exclusions) {
1509
1544
  lines.push(` ${x.id ? `${x.id} ` : ''}${x.notation} ${x.heading} (${x.file})`);
1510
1545
  lines.push(` ${x.reason}`);
1546
+ lines.push(...renderSuccessors(x, ' '));
1511
1547
  }
1512
1548
  lines.push('');
1513
1549
  }
@@ -1659,7 +1695,7 @@ export function main(argv) {
1659
1695
  } else if (opts.paths) {
1660
1696
  payload = {
1661
1697
  mode: 'paths', 'time-check': timeCheck, 'store-health': health,
1662
- paths: resolvePaths(model, opts.paths, opts.root, opts.today),
1698
+ paths: resolvePaths(model, opts.paths, opts.root, opts.today, opts.literalPaths),
1663
1699
  };
1664
1700
  } else {
1665
1701
  payload = {
@@ -0,0 +1,30 @@
1
+ /** Informational attribution of the proposed commit and its prior governance. */
2
+ import process from 'node:process';
3
+ import { parseArgs as parseFlags, runCli } from '../lib/cli.js';
4
+ import { EXIT_CODES } from '../lib/exit-codes.js';
5
+ import { withCommitSnapshot } from '../lib/commit-snapshot.js';
6
+
7
+ export const USAGE = 'usage: reverse-staged [--root <repo-root>]';
8
+
9
+ /** @param {string[]} argv @returns {Promise<number>} */
10
+ export async function main(argv) {
11
+ const { options } = parseFlags(argv, { value: ['root'] });
12
+ return withCommitSnapshot(options.root ?? process.cwd(), async ({ candidate, before, changedPaths }) => {
13
+ const paths = changedPaths();
14
+ if (!paths.length) return EXIT_CODES.CLEAN;
15
+ // Materialize both before publishing attribution. The installed runtime
16
+ // reads only immutable evidence within the shared cleanup lifetime.
17
+ const origins = [['candidate', candidate]];
18
+ if (before) origins.push(['before', before.materialize()]);
19
+ let outcome = EXIT_CODES.CLEAN;
20
+ for (const [origin, snapshot] of origins) {
21
+ process.stdout.write(`staged attribution: ${origin} ${snapshot.tree}\n`);
22
+ const status = await runCli('reverse-staged', async (args) => {
23
+ const { main: resolve } = await import('./resolve.js');
24
+ return resolve(args);
25
+ }, { usage: USAGE, argv: ['--root', snapshot.root, '--json', ...paths.map((path) => `--path=${path}`)] });
26
+ outcome = Math.max(outcome, status);
27
+ }
28
+ return outcome;
29
+ }, { skipUnchanged: true });
30
+ }
@@ -61,8 +61,8 @@
61
61
  * 2 engine failure / check-never-ran.
62
62
  */
63
63
  import process from 'node:process';
64
- import { readFileSync } from 'node:fs';
65
- import { join, resolve } from 'node:path';
64
+ import { readFileSync, realpathSync } from 'node:fs';
65
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
66
66
  import { fileURLToPath } from 'node:url';
67
67
  import { healthSummary, loadStores, isPrePromotionStatus, normalizeConceptIds, selectConcepts, storeHealth, UnknownConceptsError } from '../lib/load-stores.js';
68
68
  import { locateKitRoot } from '../lib/kit-root.js';
@@ -116,11 +116,27 @@ function checkDescriptor(ctx, concept, descriptor, i) {
116
116
 
117
117
  let input;
118
118
  try {
119
+ // Governed evidence stays inside --root, including through symlinks.
120
+ // Check before reading so a failed structural pointer cannot still feed
121
+ // host bytes to the value validator during a snapshot check.
122
+ const target = resolve(ctx.root, descriptor.source);
123
+ const outside = (root, path) => {
124
+ const rel = relative(root, path);
125
+ return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel);
126
+ };
127
+ if (outside(resolve(ctx.root), target)
128
+ || outside(realpathSync(ctx.root), realpathSync(target))) {
129
+ never('source-missing', `source ${JSON.stringify(descriptor.source)} resolves outside the repo root`);
130
+ return;
131
+ }
119
132
  input = readsDirectory
120
133
  ? listDirectory(join(ctx.root, descriptor.source))
121
134
  : readFileSync(join(ctx.root, descriptor.source), 'utf8');
122
135
  } catch (error) {
123
- never('source-missing', `cannot ${readsDirectory ? 'list source directory' : 'read source'} ${JSON.stringify(descriptor.source)}: ${error.message}`);
136
+ // A filesystem message includes the absolute (possibly temporary) root.
137
+ // The declared source and stable error code identify the repair without
138
+ // making the same candidate emit different diagnostics on every run.
139
+ never('source-missing', `cannot ${readsDirectory ? 'list source directory' : 'read source'} ${JSON.stringify(descriptor.source)}: ${error.code ?? error.message}`);
124
140
  return;
125
141
  }
126
142
 
@@ -42,7 +42,7 @@
42
42
  * missing-citation a knowledge-leaf citation whose source is empty — an
43
43
  * unsourced claim is not promotable (§3.2); presence and
44
44
  * minItems are schema checks upstream
45
- * ref-cycle a decision supersedes chain that loops (§3.3 chains
45
+ * ref-cycle a decision or leaf supersedes chain that loops (§3.3 chains
46
46
  * must be acyclic; supersedes/superseded-by mirror pairs
47
47
  * are legitimate, so only supersedes edges are walked)
48
48
  * unregistered-value a governed facet value absent from its registry
@@ -1204,6 +1204,55 @@ function checkGraduations(model, push) {
1204
1204
  }
1205
1205
  }
1206
1206
 
1207
+ /**
1208
+ * Tarjan components identify EVERY cyclic leaf, including overlapping cycles
1209
+ * whose cross-edges a back-edge-only walk misses. Findings use the existing
1210
+ * ref-cycle contract, attributed per leaf so isolated target preflight works.
1211
+ */
1212
+ function checkLeafSupersessionCycles(model, push) {
1213
+ const index = new Map();
1214
+ const low = new Map();
1215
+ const stack = [];
1216
+ const active = new Set();
1217
+ const targets = (id) => strings(model.leaves.get(id)?.record?.relates?.supersedes)
1218
+ .filter((to) => model.leaves.has(to)).sort(compare);
1219
+
1220
+ const visit = (id) => {
1221
+ index.set(id, index.size);
1222
+ low.set(id, index.get(id));
1223
+ stack.push(id);
1224
+ active.add(id);
1225
+ for (const to of targets(id)) {
1226
+ if (!index.has(to)) {
1227
+ visit(to);
1228
+ low.set(id, Math.min(low.get(id), low.get(to)));
1229
+ } else if (active.has(to)) {
1230
+ low.set(id, Math.min(low.get(id), index.get(to)));
1231
+ }
1232
+ }
1233
+ if (low.get(id) !== index.get(id)) return;
1234
+ const members = [];
1235
+ let member;
1236
+ do {
1237
+ member = stack.pop();
1238
+ active.delete(member);
1239
+ members.push(member);
1240
+ } while (member !== id);
1241
+ if (members.length === 1 && !targets(id).includes(id)) return;
1242
+ members.sort(compare);
1243
+ for (const cyclic of members) {
1244
+ push({
1245
+ severity: 'error', code: 'ref-cycle', id: cyclic,
1246
+ file: model.leaves.get(cyclic).file, path: 'relates.supersedes',
1247
+ message: `supersedes cycle includes: ${members.join(', ')} — leaf chains must be acyclic (UCS-1226)`,
1248
+ });
1249
+ }
1250
+ };
1251
+ for (const id of [...model.leaves.keys()].sort(compare)) {
1252
+ if (!index.has(id)) visit(id);
1253
+ }
1254
+ }
1255
+
1207
1256
  function checkDecisionCycles(model, push) {
1208
1257
  const seen = new Set(); // canonical cycle keys — each loop reported once
1209
1258
  const color = new Map(); // 0/undefined = white, 1 = on stack, 2 = done
@@ -1264,6 +1313,7 @@ export function runChecks(model, repoRoot = model.root) {
1264
1313
  checkEditions(model, push);
1265
1314
  checkGraduations(model, push);
1266
1315
  checkDecisionCycles(model, push);
1316
+ checkLeafSupersessionCycles(model, push);
1267
1317
  findings.sort((a, b) =>
1268
1318
  compare(a.file, b.file) || compare(a.path, b.path) || compare(a.code, b.code) || compare(a.id, b.id));
1269
1319
  return findings;
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ // Load failures must report failure (2), never findings (1).
3
+ try {
4
+ const [{ boot }, command] = await Promise.all([
5
+ import('./lib/boot.js'),
6
+ import('./commands/commit-check.js'),
7
+ ]);
8
+ process.exitCode = await boot('commit-check', command);
9
+ } catch (error) {
10
+ process.stderr.write(`commit-check: internal failure — the engine could not be loaded\n${error?.stack ?? error}\n`);
11
+ process.exitCode = 2;
12
+ }
@@ -0,0 +1,155 @@
1
+ /** Raw Git objects as disposable evidence; never checkout filters or client code. */
2
+ import { spawnSync } from 'node:child_process';
3
+ import { copyFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
+ import process from 'node:process';
7
+
8
+ /**
9
+ * @typedef {{root: string, tree: string}} TreeSnapshot
10
+ * @typedef {{candidate: TreeSnapshot, before: null | {tree: string, materialize: () => TreeSnapshot}, changedPaths: () => string[]}} CommitSnapshot
11
+ *
12
+ * The callback owns no files. Candidate evidence and optional before evidence
13
+ * share this lifetime; immutable tree IDs pin provenance for later attribution.
14
+ * Before materialization is lazy: commit validation checks only the candidate.
15
+ * @param {string} repoRoot
16
+ * @param {(snapshot: CommitSnapshot) => Promise<number>} check
17
+ * @param {{skipUnchanged?: boolean}} [options] Attribution can skip an empty diff.
18
+ */
19
+ export async function withCommitSnapshot(repoRoot, check, { skipUnchanged = false } = {}) {
20
+ const temporary = mkdtempSync(join(tmpdir(), 'unknown-knowledge-commit-'));
21
+ const env = { ...process.env, GIT_NO_REPLACE_OBJECTS: '1', GIT_NO_LAZY_FETCH: '1' };
22
+ const git = (args, { missing = false } = {}) => {
23
+ const result = spawnSync('git', ['-c', 'core.fsmonitor=false', '-C', repoRoot, ...args], { env, maxBuffer: 64 * 1024 * 1024 });
24
+ if (missing && result.status === 1) return null;
25
+ if (result.status !== 0) throw new Error(`snapshot: git ${args[0]} failed: ${result.error?.message ?? result.stderr.toString().trim()}`);
26
+ return result.stdout;
27
+ };
28
+ try {
29
+ repoRoot = realpathSync(repoRoot);
30
+ const top = git(['rev-parse', '--show-toplevel']).toString().replace(/\n$/, '');
31
+ if (realpathSync(top) !== repoRoot) {
32
+ throw new Error('snapshot: --root must be the Git repository root');
33
+ }
34
+ let beforeTree = null;
35
+ if (git(['rev-parse', '--verify', '--quiet', 'HEAD'], { missing: true })) {
36
+ beforeTree = git(['rev-parse', '--verify', 'HEAD^{tree}']).toString().trim();
37
+ } else {
38
+ // Only an absent branch is an unborn HEAD. A broken existing ref must
39
+ // never be interpreted as an empty before snapshot.
40
+ const ref = git(['symbolic-ref', 'HEAD']).toString().trim();
41
+ if (git(['show-ref', '--verify', '--quiet', ref], { missing: true }) !== null) {
42
+ throw new Error('snapshot: HEAD exists but its tree could not be read');
43
+ }
44
+ }
45
+ // write-tree may refresh index metadata: give it a private copy, including
46
+ // Git's alternate index during a path-limited commit.
47
+ const index = git(['rev-parse', '--git-path', 'index']).toString().replace(/\n$/, '');
48
+ const privateIndex = join(temporary, 'index');
49
+ try {
50
+ copyFileSync(resolve(repoRoot, index), privateIndex);
51
+ } catch (error) {
52
+ // A new repo may have no index yet. Git writes the empty tree from the
53
+ // absent private index; other missing/unreadable indexes still fail.
54
+ if (error.code !== 'ENOENT' || beforeTree !== null) throw error;
55
+ }
56
+ env.GIT_INDEX_FILE = privateIndex;
57
+ const tree = git(['write-tree']).toString().trim();
58
+ const candidate = { root: join(temporary, 'candidate'), tree };
59
+ let beforeSnapshot;
60
+ const before = beforeTree === null ? null : {
61
+ tree: beforeTree,
62
+ materialize: () => {
63
+ if (!beforeSnapshot) {
64
+ const root = join(temporary, 'before');
65
+ materializeTree(root, beforeTree, git);
66
+ beforeSnapshot = { root, tree: beforeTree };
67
+ }
68
+ return beforeSnapshot;
69
+ },
70
+ };
71
+ let pathsCache;
72
+ const changedPaths = () => {
73
+ if (pathsCache) return pathsCache;
74
+ const base = beforeTree ?? git(['hash-object', '-w', '-t', 'tree', '--stdin']).toString().trim();
75
+ // Pin similarity and exhaustive-search limits: host diff.renameLimit
76
+ // must not change the path set for the same immutable trees.
77
+ const bytes = git(['diff-tree', '--no-commit-id', '--name-status', '-r', '-z', '--no-ext-diff', '--no-textconv', '--find-renames=50%', '--find-copies=50%', '--find-copies-harder', '-l1000', base, tree, '--']);
78
+ const text = bytes.toString();
79
+ if (!Buffer.from(text).equals(bytes)) throw new Error('snapshot: non-UTF-8 changed paths cannot be attributed faithfully');
80
+ if (text && !text.endsWith('\0')) throw new Error('snapshot: incomplete Git change records');
81
+ const records = text ? text.slice(0, -1).split('\0') : [];
82
+ const paths = [];
83
+ for (let i = 0; i < records.length;) {
84
+ const status = records[i++];
85
+ if (!/^(?:[ADMT]|[RC]\d+)$/.test(status)) throw new Error(`snapshot: unsupported Git change status ${JSON.stringify(status)}`);
86
+ const count = /^[RC]/.test(status) ? 2 : 1;
87
+ for (let n = 0; n < count; n += 1) {
88
+ const path = records[i++];
89
+ if (!path) throw new Error('snapshot: incomplete Git change records');
90
+ paths.push(path);
91
+ }
92
+ }
93
+ pathsCache = [...new Set(paths)];
94
+ return pathsCache;
95
+ };
96
+ if (skipUnchanged && changedPaths().length === 0) return 0;
97
+ materializeTree(candidate.root, tree, git);
98
+ return await check({ candidate, before, changedPaths });
99
+ } finally {
100
+ try {
101
+ rmSync(temporary, { recursive: true, force: true, maxRetries: 3 });
102
+ } catch (error) {
103
+ throw new Error(`snapshot cleanup failed at ${JSON.stringify(temporary)}: ${error.message}`, { cause: error });
104
+ }
105
+ }
106
+ }
107
+
108
+ function materializeTree(snapshotRoot, tree, git) {
109
+ mkdirSync(snapshotRoot);
110
+ const listing = git(['ls-tree', '-rz', '--full-tree', tree]);
111
+ const text = listing.toString();
112
+ if (!Buffer.from(text).equals(listing)) throw new Error('snapshot: non-UTF-8 Git paths cannot be materialized faithfully');
113
+ const entries = text.split('\0').filter(Boolean);
114
+ const links = [];
115
+ for (const entry of entries) {
116
+ const [header, path] = [entry.slice(0, entry.indexOf('\t')), entry.slice(entry.indexOf('\t') + 1)];
117
+ const [mode, type, object] = header.split(' ');
118
+ if (type !== 'blob' || !['100644', '100755', '120000'].includes(mode)) {
119
+ throw new Error(`snapshot: unsupported Git entry ${JSON.stringify(path)} (${mode})`);
120
+ }
121
+ const target = resolve(snapshotRoot, path);
122
+ if (outside(snapshotRoot, target) || target === snapshotRoot) throw new Error('snapshot: Git path escapes its root');
123
+ mkdirSync(dirname(target), { recursive: true });
124
+ const bytes = git(['cat-file', 'blob', object]);
125
+ if (mode === '120000') {
126
+ const link = bytes.toString();
127
+ if (!Buffer.from(link).equals(bytes)) {
128
+ throw new Error(`snapshot: non-UTF-8 symlink target at ${JSON.stringify(path)} cannot be materialized faithfully`);
129
+ }
130
+ if (isAbsolute(link) || outside(snapshotRoot, resolve(dirname(target), link))) {
131
+ throw new Error(`snapshot: symlink ${JSON.stringify(path)} points outside the candidate`);
132
+ }
133
+ links.push({ link, target });
134
+ } else {
135
+ writeFileSync(target, bytes, { mode: Number.parseInt(mode, 8), flag: 'wx' });
136
+ }
137
+ }
138
+ for (const { link, target } of links) symlinkSync(link, target);
139
+ // Check complete link chains before either validator can read a store.
140
+ for (const entry of entries.filter((entry) => entry.startsWith('120000 '))) {
141
+ const path = entry.slice(entry.indexOf('\t') + 1);
142
+ try {
143
+ if (outside(realpathSync(snapshotRoot), realpathSync(join(snapshotRoot, path)))) {
144
+ throw new Error(`snapshot: symlink ${JSON.stringify(path)} resolves outside the candidate`);
145
+ }
146
+ } catch (error) {
147
+ if (error.code !== 'ENOENT') throw error; // dangling evidence remains missing
148
+ }
149
+ }
150
+ }
151
+
152
+ function outside(root, target) {
153
+ const path = relative(root, target);
154
+ return path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path);
155
+ }
@@ -842,6 +842,7 @@ function gatherRollup(joined) {
842
842
  ? `declares applies.jurisdictions [${leaf.applies.join(', ')}] — the document is scoped to [${scopes.join(', ')}], which this leaf does not cover; verify applicability (UCS-1156)`
843
843
  : null,
844
844
  demotions: leaf.demotions,
845
+ 'superseded-by': leaf['superseded-by'],
845
846
  });
846
847
  }
847
848
  return rollup.sort((a, b) =>
@@ -22,7 +22,8 @@
22
22
  *
23
23
  * Both repos look identical from here. Picking either silently reads one Store
24
24
  * and ignores the other — a confident wrong answer, which is the failure class
25
- * this engine exists to prevent. So an ambiguous layout REFUSES: every surface
25
+ * this engine exists to prevent. An explicit .unknown-knowledge.json selection settles this choice across all surfaces.
26
+ * Without that selection, an ambiguous layout REFUSES: every surface
26
27
  * fails identically (exit 2) and names both candidates, rather than four
27
28
  * surfaces agreeing on one Store while the audit quietly reads the other.
28
29
  *
@@ -33,13 +34,16 @@
33
34
  * Stores absent entirely is not an error: the loader reports missing-store
34
35
  * warnings and the audit proposes every anchor.
35
36
  */
36
- import { statSync } from 'node:fs';
37
+ import { statSync, lstatSync, readFileSync } from 'node:fs';
37
38
  import { join } from 'node:path';
38
39
  import { EngineRefusal } from './engine-refusal.js';
39
40
 
40
41
  /** The §9.1/D-016 seeded kit directory name. Renames are a later seam. */
41
42
  export const KIT_DIR_DEFAULT = 'unknown-knowledge';
42
43
 
44
+ /** Versioned selection for repositories containing both supported layouts. */
45
+ export const KIT_LAYOUT_FILE = '.unknown-knowledge.json';
46
+
43
47
  /** The human-confirmed survey boundary (§6), written at the kit root. */
44
48
  export const SCOPE_FILE = 'survey-scope.yaml';
45
49
 
@@ -83,13 +87,31 @@ export class AmbiguousKitLayout extends EngineRefusal {
83
87
  * both exist, so no surface can know which Store is authoritative
84
88
  */
85
89
  export function locateKit(root) {
90
+ const selection = join(root, KIT_LAYOUT_FILE);
91
+ const selectionStat = lstatSync(selection, { throwIfNoEntry: false });
92
+ if (selectionStat) {
93
+ if (!selectionStat.isFile()) throw new AmbiguousKitLayout(`${KIT_LAYOUT_FILE} must be a regular JSON file`);
94
+ let config;
95
+ try {
96
+ config = JSON.parse(readFileSync(selection, 'utf8'));
97
+ } catch (error) {
98
+ throw new AmbiguousKitLayout(`${KIT_LAYOUT_FILE} could not be read as JSON: ${error.message}`, { cause: error });
99
+ }
100
+ if (!config || Array.isArray(config) || Object.keys(config).length !== 1
101
+ || !['.', KIT_DIR_DEFAULT].includes(config.kitRoot)) {
102
+ throw new AmbiguousKitLayout(`${KIT_LAYOUT_FILE} must contain only "kitRoot", set to "." or "${KIT_DIR_DEFAULT}"`);
103
+ }
104
+ const kitRoot = join(root, config.kitRoot);
105
+ if (!isDir(kitRoot)) throw new AmbiguousKitLayout(`${KIT_LAYOUT_FILE} selects a missing kit directory`);
106
+ return { kitRoot, kitPrefixes: [...(config.kitRoot === '.' ? KIT_ZONE_AT_ROOT : [KIT_DIR_DEFAULT]), KIT_LAYOUT_FILE] };
107
+ }
86
108
  const nested = join(root, KIT_DIR_DEFAULT);
87
109
  if (isDir(nested)) {
88
110
  if (looksLikeStoreRoot(root)) {
89
111
  throw new AmbiguousKitLayout(
90
112
  `two candidate kit roots under ${JSON.stringify(root)}: the seeded ${KIT_DIR_DEFAULT}/ and stores at the root itself. `
91
113
  + 'Which one is authoritative is not knowable from here, and guessing would let the reverse audit read a different '
92
- + 'store than the validators (PRD §4, single health model). Point --root at the intended kit root, or remove the stale one.',
114
+ + 'store than the validators (PRD §4, single health model). Select the authoritative layout with .unknown-knowledge.json (kitRoot: "." or "unknown-knowledge"), or remove the stale one. Keep --root at the repository root.',
93
115
  );
94
116
  }
95
117
  return { kitRoot: nested, kitPrefixes: [KIT_DIR_DEFAULT] };