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.
- package/README.md +56 -6
- package/cli/kit.manifest.yaml +2 -3
- package/package.json +1 -1
- package/payload/docs/README.md +135 -0
- package/payload/docs/ci-wiring.md +25 -0
- package/payload/engine/commands/commit-check.js +49 -0
- package/payload/engine/commands/preflight.js +29 -430
- package/payload/engine/commands/resolve.js +71 -35
- package/payload/engine/commands/reverse-staged.js +30 -0
- package/payload/engine/commands/validate-values.js +19 -3
- package/payload/engine/commands/validate.js +51 -1
- package/payload/engine/commit-check.js +12 -0
- package/payload/engine/lib/commit-snapshot.js +155 -0
- package/payload/engine/lib/coverage.js +1 -0
- package/payload/engine/lib/kit-root.js +25 -3
- package/payload/engine/lib/load-stores.js +14 -0
- package/payload/engine/lib/preflight.js +122 -0
- package/payload/engine/lib/time-verdicts.js +5 -6
- package/payload/engine/lib/verdicts.js +260 -0
- package/payload/engine/reverse-staged.js +12 -0
- package/payload/hooks/pre-commit +7 -7
- package/payload/hooks/reverse-lookup +7 -61
- package/payload/protocol/AGENTS.md +321 -52
- package/payload/protocol/skills/kb-build.md +58 -3
- package/payload/protocol/skills/knowledge-reflect.md +95 -8
- package/payload/wrappers/cursor.mdc +7 -8
- package/payload/wrappers/pointer.md +6 -7
|
@@ -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
|
|
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
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
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
|
|
185
|
-
*
|
|
186
|
-
*
|
|
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
|
|
419
|
-
*
|
|
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
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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):
|
|
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
|
-
*
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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.
|
|
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).
|
|
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] };
|