rcf-lite 0.28.1 → 0.28.4
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/CHANGELOG.md +310 -0
- package/bin/rcf.js +4 -0
- package/fixtures/canary-manifest.json +9 -9
- package/package.json +1 -1
- package/rcf/adrs/adr-4120-canonical-json-hashing.json +30 -0
- package/rcf/adrs/adr-4121-define-sidecars.json +30 -0
- package/rcf/adrs/adr-4122-define-gates-blocking-and-optout.json +30 -0
- package/rcf/adrs/adr-4123-readiness-single-object.json +30 -0
- package/rcf/code-nodes/cn-602.json +3 -2
- package/rcf/code-nodes/cn-700.json +23 -0
- package/rcf/code-nodes/cn-701.json +14 -0
- package/rcf/code-nodes/cn-702.json +14 -0
- package/rcf/code-nodes/cn-703.json +16 -0
- package/rcf/code-nodes/cn-720.json +15 -0
- package/rcf/code-nodes/cn-721.json +15 -0
- package/rcf/code-nodes/cn-722.json +15 -0
- package/rcf/code-nodes/cn-723.json +18 -0
- package/rcf/code-nodes/cn-724.json +14 -0
- package/rcf/code-nodes/cn-725.json +21 -0
- package/rcf/code-nodes/cn-726.json +17 -0
- package/rcf/code-nodes/cn-727.json +14 -0
- package/rcf/code-nodes/cn-728.json +21 -0
- package/rcf/code-nodes/cn-729.json +18 -0
- package/rcf/code-nodes/cn-730.json +14 -0
- package/rcf/fbs/fbs-181.json +3 -2
- package/rcf/fbs/fbs-190.json +37 -0
- package/rcf/fbs/fbs-191.json +34 -0
- package/rcf/fbs/fbs-192.json +17 -0
- package/rcf/fbs/fbs-193.json +37 -0
- package/rcf/fbs/fbs-194.json +35 -0
- package/rcf/fbs/fbs-195.json +37 -0
- package/rcf/fbs/fbs-196.json +37 -0
- package/rcf/fbs/fbs-197.json +41 -0
- package/rcf/requirements/req-001.json +2 -1
- package/rcf/requirements/req-002.json +2 -1
- package/rcf/requirements/req-003.json +2 -1
- package/rcf/requirements/req-004.json +2 -1
- package/rcf/requirements/req-005.json +2 -1
- package/rcf/requirements/req-006.json +2 -1
- package/rcf/requirements/req-007.json +2 -1
- package/rcf/requirements/req-008.json +2 -1
- package/rcf/requirements/req-009.json +2 -1
- package/rcf/requirements/req-010.json +5 -2
- package/rcf/requirements/req-011.json +2 -1
- package/rcf/requirements/req-012.json +2 -1
- package/rcf/requirements/req-013.json +2 -1
- package/rcf/requirements/req-014.json +2 -1
- package/rcf/requirements/req-015.json +2 -1
- package/rcf/requirements/req-016.json +5 -2
- package/rcf/requirements/req-017.json +6 -2
- package/rcf/requirements/req-018.json +5 -2
- package/rcf/requirements/req-019.json +5 -2
- package/rcf/requirements/req-020.json +5 -2
- package/rcf/requirements/req-021.json +5 -2
- package/rcf/requirements/req-022.json +5 -2
- package/rcf/requirements/req-023.json +6 -4
- package/rcf/requirements/req-024.json +5 -2
- package/rcf/requirements/req-025.json +5 -2
- package/rcf/requirements/req-026.json +5 -2
- package/rcf/requirements/req-027.json +5 -2
- package/rcf/requirements/req-028.json +5 -2
- package/rcf/requirements/req-029.json +5 -2
- package/rcf/requirements/req-030.json +5 -2
- package/rcf/requirements/req-040.json +5 -2
- package/rcf/requirements/req-041.json +5 -2
- package/rcf/requirements/req-042.json +5 -2
- package/rcf/requirements/req-043.json +5 -2
- package/rcf/requirements/req-050.json +5 -2
- package/rcf/requirements/req-051.json +5 -2
- package/rcf/requirements/req-052.json +5 -2
- package/rcf/requirements/req-053.json +5 -2
- package/rcf/requirements/req-054.json +5 -2
- package/rcf/requirements/req-060.json +5 -2
- package/rcf/requirements/req-061.json +5 -2
- package/rcf/requirements/req-062.json +5 -2
- package/rcf/requirements/req-063.json +5 -2
- package/rcf/requirements/req-070.json +5 -2
- package/rcf/requirements/req-071.json +5 -2
- package/rcf/requirements/req-072.json +5 -2
- package/rcf/requirements/req-073.json +5 -2
- package/rcf/requirements/req-074.json +5 -2
- package/rcf/requirements/req-075.json +5 -2
- package/rcf/requirements/req-076.json +5 -2
- package/rcf/requirements/req-077.json +5 -2
- package/rcf/requirements/req-080.json +5 -2
- package/rcf/requirements/req-081.json +5 -2
- package/rcf/requirements/req-082.json +5 -2
- package/rcf/requirements/req-083.json +5 -2
- package/rcf/requirements/req-084.json +5 -2
- package/rcf/requirements/req-085.json +5 -2
- package/rcf/requirements/req-086.json +5 -2
- package/rcf/requirements/req-090.json +5 -2
- package/rcf/requirements/req-091.json +5 -2
- package/rcf/requirements/req-092.json +5 -2
- package/rcf/requirements/req-093.json +5 -2
- package/rcf/requirements/req-094.json +5 -2
- package/rcf/requirements/req-100.json +5 -2
- package/rcf/requirements/req-101.json +5 -2
- package/rcf/requirements/req-102.json +5 -2
- package/rcf/requirements/req-103.json +5 -2
- package/rcf/requirements/req-104.json +5 -2
- package/rcf/requirements/req-110.json +5 -2
- package/rcf/requirements/req-111.json +5 -2
- package/rcf/requirements/req-112.json +5 -2
- package/rcf/requirements/req-113.json +5 -2
- package/rcf/requirements/req-114.json +5 -2
- package/rcf/requirements/req-115.json +5 -2
- package/rcf/requirements/req-120.json +5 -2
- package/rcf/requirements/req-121.json +5 -2
- package/rcf/requirements/req-122.json +5 -2
- package/rcf/requirements/req-123.json +5 -2
- package/rcf/requirements/req-124.json +5 -2
- package/rcf/requirements/req-125.json +5 -2
- package/rcf/requirements/req-130.json +6 -2
- package/rcf/requirements/req-131.json +6 -2
- package/rcf/requirements/req-132.json +6 -2
- package/rcf/requirements/req-133.json +6 -2
- package/rcf/requirements/req-134.json +6 -2
- package/rcf/requirements/req-140.json +5 -2
- package/rcf/requirements/req-145.json +5 -2
- package/rcf/requirements/req-150.json +6 -2
- package/rcf/requirements/req-151.json +6 -2
- package/rcf/requirements/req-152.json +6 -2
- package/rcf/requirements/req-153.json +6 -2
- package/rcf/requirements/req-154.json +5 -2
- package/rcf/requirements/req-155.json +4 -1
- package/rcf/requirements/req-156.json +4 -1
- package/rcf/requirements/req-157.json +4 -1
- package/rcf/requirements/req-158.json +4 -1
- package/rcf/requirements/req-159.json +4 -1
- package/rcf/requirements/req-160.json +4 -1
- package/rcf/requirements/req-161.json +4 -1
- package/rcf/requirements/req-162.json +4 -1
- package/rcf/requirements/req-163.json +5 -2
- package/rcf/requirements/req-164.json +5 -2
- package/rcf/requirements/req-165.json +5 -2
- package/rcf/requirements/req-170.json +33 -0
- package/rcf/requirements/req-171.json +44 -0
- package/rcf/requirements/req-172.json +25 -0
- package/rcf/requirements/req-173.json +25 -0
- package/rcf/requirements/req-174.json +25 -0
- package/rcf/requirements/req-175.json +25 -0
- package/rcf/tacs/tac-4103-feedback-redact.json +2 -2
- package/rcf/tacs/tac-4120-define-delta-and-freeze.json +21 -0
- package/rcf/tacs/tac-4121-define-ledgers.json +23 -0
- package/rcf/tacs/tac-4122-define-gates.json +25 -0
- package/rcf/tacs/tac-4123-define-readiness.json +25 -0
- package/rcf/test-suites/ts-188.json +10 -2
- package/rcf/test-suites/ts-191.json +19 -5
- package/rcf/test-suites/ts-204.json +30 -0
- package/rcf/test-suites/ts-205.json +22 -0
- package/rcf/test-suites/ts-206.json +22 -0
- package/rcf/test-suites/ts-207.json +22 -0
- package/rcf/test-suites/ts-208.json +22 -0
- package/rcf/test-suites/ts-209.json +22 -0
- package/rcf/test-suites/ts-210.json +22 -0
- package/rcf/test-suites/ts-211.json +22 -0
- package/rcf/test-suites/ts-212.json +22 -0
- package/rcf/test-suites/ts-213.json +50 -0
- package/rcf/test-suites/ts-214.json +74 -0
- package/rcf/test-suites/ts-215.json +67 -0
- package/rcf/test-suites/ts-216.json +78 -0
- package/rcf/test-suites/ts-217.json +62 -0
- package/rcf/user-stories/us-15601.json +10 -1
- package/rcf/user-stories/us-15701.json +4 -4
- package/rcf/user-stories/us-17001.json +27 -0
- package/rcf/user-stories/us-17002.json +22 -0
- package/rcf/user-stories/us-17003.json +22 -0
- package/rcf/user-stories/us-17004.json +22 -0
- package/rcf/user-stories/us-17005.json +22 -0
- package/rcf/user-stories/us-17006.json +21 -0
- package/rcf/user-stories/us-17007.json +21 -0
- package/rcf/user-stories/us-17008.json +22 -0
- package/rcf/user-stories/us-17101.json +22 -0
- package/rcf/user-stories/us-17102.json +22 -0
- package/rcf/user-stories/us-17201.json +38 -0
- package/rcf/user-stories/us-17301.json +38 -0
- package/rcf/user-stories/us-17401.json +58 -0
- package/rcf/user-stories/us-17501.json +48 -0
- package/releases/releases.yaml +31 -1
- package/src/browser-verify/manifest-writer.js +9 -5
- package/src/cli/browser-verify.js +9 -1
- package/src/cli/feedback.js +316 -32
- package/src/cli/help.js +11 -0
- package/src/cli/intake.js +25 -4
- package/src/cli/ledger.js +399 -0
- package/src/cli/preflight.js +24 -3
- package/src/cli/readiness.js +280 -0
- package/src/cli/review.js +9 -1
- package/src/cli/ui-baseline.js +14 -1
- package/src/core/store/index.js +5 -0
- package/src/core/store/validate-composed-record.js +95 -0
- package/src/core/store/writer.js +86 -6
- package/src/define/freeze-record.js +232 -0
- package/src/define/ledgers.js +513 -0
- package/src/feedback/gh.js +27 -1
- package/src/feedback/redact-allowlist.json +7 -0
- package/src/feedback/redact.js +126 -11
- package/src/feedback/render.js +34 -12
- package/src/intake/manifest-writer.js +6 -3
- package/src/intake/orchestrator.js +44 -7
- package/src/preflight/manifest-writer.js +11 -11
- package/src/query/delta.js +276 -0
- package/src/query/gates.js +832 -0
- package/src/query/index.js +36 -0
- package/src/query/readiness.js +333 -0
- package/src/req-baseline/sweep.js +34 -5
- package/src/server/index.js +37 -0
- package/src/server/routes.js +65 -3
- package/src/ui-baseline/manifest-writer.js +11 -10
- package/src/view/doc-renderers/helpers.js +39 -2
- package/src/view/doc-renderers/req.js +10 -2
- package/src/view/doc-renderers/tad.js +67 -6
- package/src/view/doc-renderers/user-story.js +10 -5
- package/src/view/html-page.js +347 -28
- package/src/view/index.js +14 -1
- package/src/view/live-client.js +22 -2
- package/src/view/product-map.js +751 -0
- package/src/view/style.css +383 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
// Sidecar ledgers under `rcf/define/` (REQ-173; proposal §2.5, §3.2,
|
|
2
|
+
// §8.2 v3).
|
|
3
|
+
//
|
|
4
|
+
// Four ledgers, each one JSON file, each owned by rcf-lite (local
|
|
5
|
+
// schema in this module until schemas 0.7, ADR-4121). Every entry is
|
|
6
|
+
// numbered in one sequence per ledger for the life of the project;
|
|
7
|
+
// numbers never reset, and are what the freeze record's
|
|
8
|
+
// `briefStatements` high-water mark counts against (proposal §2.5).
|
|
9
|
+
//
|
|
10
|
+
// rcf/define/brief-ledger.json D1 brief statements
|
|
11
|
+
// rcf/define/decisions-ledger.json D7 open / answered decisions
|
|
12
|
+
// rcf/define/concern-ledger.json D5 (concern, REQ) pair records
|
|
13
|
+
// rcf/define/probe-ledger.json D6 probe findings
|
|
14
|
+
//
|
|
15
|
+
// Kinds and fields:
|
|
16
|
+
//
|
|
17
|
+
// brief.statements[] { id, kind, text, source, addedAt, status,
|
|
18
|
+
// resolvedAt?, resolvedBy? }
|
|
19
|
+
// kind = capability | constraint | actor | entity | externalSystem
|
|
20
|
+
// | surface | outOfScope | openQuestion | amendment
|
|
21
|
+
//
|
|
22
|
+
// decisions.decisions[] { id, question, options: [{ letter, text }],
|
|
23
|
+
// default: <letter | null>, blocks, addedAt,
|
|
24
|
+
// status, answer?, answeredAt? }
|
|
25
|
+
// `decision list` prints the numbered format from #241: one
|
|
26
|
+
// decision per item; options with letters; the default; what it
|
|
27
|
+
// blocks. Slice 2 (Readiness) is the second consumer.
|
|
28
|
+
//
|
|
29
|
+
// concerns.concerns[] { id, concern, reqId, disposition, reason?,
|
|
30
|
+
// addedAt, status, resolvedAt? }
|
|
31
|
+
// disposition = applied | waived
|
|
32
|
+
//
|
|
33
|
+
// probes.probes[] { id, reqId, finding, severity, addedAt,
|
|
34
|
+
// status, resolvedAt? }
|
|
35
|
+
// severity = low | medium | high
|
|
36
|
+
//
|
|
37
|
+
// status on every entry is `open | resolved`. `resolve` flips it to
|
|
38
|
+
// resolved and stamps `resolvedAt`; `add` stamps `addedAt` and mints
|
|
39
|
+
// the next id (highest current + 1).
|
|
40
|
+
//
|
|
41
|
+
// Loader returns an empty ledger (`{ statements: [] }` etc.) when the
|
|
42
|
+
// file is missing so callers can compose without a per-ledger null
|
|
43
|
+
// check; a malformed file raises a clear error like the freeze
|
|
44
|
+
// record. Every write goes through the writer, which validates the
|
|
45
|
+
// full body first (a bad in-memory record never lands as JSON).
|
|
46
|
+
|
|
47
|
+
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
48
|
+
import { dirname, join } from 'node:path';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The four ledger identifiers. Ledger name -> filename convention.
|
|
52
|
+
* These are the values `rcf define ledger <name>` accepts.
|
|
53
|
+
*/
|
|
54
|
+
export const LEDGER_NAMES = /** @type {const} */ (['brief', 'decisions', 'concerns', 'probes']);
|
|
55
|
+
|
|
56
|
+
/** @typedef {(typeof LEDGER_NAMES)[number]} LedgerName */
|
|
57
|
+
|
|
58
|
+
const RELATIVE_DIR = 'rcf/define';
|
|
59
|
+
|
|
60
|
+
/** Per-ledger config: filename, array key on the body, entry schema. */
|
|
61
|
+
const LEDGER_CONFIG = /** @type {const} */ ({
|
|
62
|
+
brief: { file: 'brief-ledger.json', arrayKey: 'statements' },
|
|
63
|
+
decisions: { file: 'decisions-ledger.json', arrayKey: 'decisions' },
|
|
64
|
+
concerns: { file: 'concern-ledger.json', arrayKey: 'concerns' },
|
|
65
|
+
probes: { file: 'probe-ledger.json', arrayKey: 'probes' },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export const BRIEF_KINDS = /** @type {const} */ ([
|
|
69
|
+
'capability', 'constraint', 'actor', 'entity', 'externalSystem',
|
|
70
|
+
'surface', 'outOfScope', 'openQuestion', 'amendment',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
const CONCERN_DISPOSITIONS = /** @type {const} */ (['applied', 'waived']);
|
|
74
|
+
const PROBE_SEVERITIES = /** @type {const} */ (['low', 'medium', 'high']);
|
|
75
|
+
const STATUS_VALUES = /** @type {const} */ (['open', 'resolved']);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Local relative path of a ledger file (project-root-relative).
|
|
79
|
+
*
|
|
80
|
+
* @param {LedgerName} name
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
export function ledgerRelPath(name) {
|
|
84
|
+
const cfg = LEDGER_CONFIG[name];
|
|
85
|
+
if (!cfg) throw new TypeError(`Unknown ledger '${name}'.`);
|
|
86
|
+
return `${RELATIVE_DIR}/${cfg.file}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Absolute path of a ledger file for a project root.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} projectRoot
|
|
93
|
+
* @param {LedgerName} name
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
export function ledgerPath(projectRoot, name) {
|
|
97
|
+
return join(projectRoot, ledgerRelPath(name));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class LedgerError extends Error {
|
|
101
|
+
/**
|
|
102
|
+
* @param {string} message
|
|
103
|
+
* @param {object} [opts]
|
|
104
|
+
* @param {string} [opts.filePath]
|
|
105
|
+
* @param {'parseFailure' | 'schemaFailure' | 'ioFailure' | 'usage'} [opts.code]
|
|
106
|
+
* @param {string} [opts.field]
|
|
107
|
+
*/
|
|
108
|
+
constructor(message, opts = {}) {
|
|
109
|
+
super(message);
|
|
110
|
+
this.name = 'LedgerError';
|
|
111
|
+
this.filePath = opts.filePath;
|
|
112
|
+
this.code = opts.code ?? 'schemaFailure';
|
|
113
|
+
this.field = opts.field;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Empty body for a named ledger (matches the shape a loader returns
|
|
121
|
+
* when the file is missing).
|
|
122
|
+
*
|
|
123
|
+
* @param {LedgerName} name
|
|
124
|
+
* @returns {Record<string, unknown>}
|
|
125
|
+
*/
|
|
126
|
+
export function emptyLedger(name) {
|
|
127
|
+
const cfg = LEDGER_CONFIG[name];
|
|
128
|
+
if (!cfg) throw new TypeError(`Unknown ledger '${name}'.`);
|
|
129
|
+
return { [cfg.arrayKey]: [] };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Validate one entry against its ledger schema. Throws `LedgerError`
|
|
134
|
+
* on the first violation.
|
|
135
|
+
*
|
|
136
|
+
* @param {LedgerName} name
|
|
137
|
+
* @param {unknown} entry
|
|
138
|
+
* @param {string} [filePath]
|
|
139
|
+
* @param {string} [where] path segment for error messages, e.g.
|
|
140
|
+
* 'statements[0]'
|
|
141
|
+
*/
|
|
142
|
+
function validateEntry(name, entry, filePath, where = 'entry') {
|
|
143
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
144
|
+
throw new LedgerError(`${name} ${where} must be an object.`, {
|
|
145
|
+
filePath, field: where,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const e = /** @type {Record<string, unknown>} */ (entry);
|
|
149
|
+
|
|
150
|
+
if (typeof e.id !== 'number' || !Number.isInteger(e.id) || e.id < 1) {
|
|
151
|
+
throw new LedgerError(`${name} ${where}.id must be a positive integer.`, {
|
|
152
|
+
filePath, field: `${where}.id`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (typeof e.addedAt !== 'string' || !ISO_RE.test(e.addedAt)) {
|
|
156
|
+
throw new LedgerError(`${name} ${where}.addedAt must be an ISO-8601 string.`, {
|
|
157
|
+
filePath, field: `${where}.addedAt`,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if (typeof e.status !== 'string' || !STATUS_VALUES.includes(/** @type {any} */ (e.status))) {
|
|
161
|
+
throw new LedgerError(
|
|
162
|
+
`${name} ${where}.status must be one of: ${STATUS_VALUES.join(', ')}.`,
|
|
163
|
+
{ filePath, field: `${where}.status` },
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (e.status === 'resolved') {
|
|
167
|
+
if (typeof e.resolvedAt !== 'string' || !ISO_RE.test(e.resolvedAt)) {
|
|
168
|
+
throw new LedgerError(
|
|
169
|
+
`${name} ${where}.resolvedAt must be an ISO-8601 string when status is resolved.`,
|
|
170
|
+
{ filePath, field: `${where}.resolvedAt` },
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (name === 'brief') {
|
|
176
|
+
if (typeof e.kind !== 'string' || !BRIEF_KINDS.includes(/** @type {any} */ (e.kind))) {
|
|
177
|
+
throw new LedgerError(
|
|
178
|
+
`brief ${where}.kind must be one of: ${BRIEF_KINDS.join(', ')}.`,
|
|
179
|
+
{ filePath, field: `${where}.kind` },
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (typeof e.text !== 'string' || e.text.length === 0) {
|
|
183
|
+
throw new LedgerError(`brief ${where}.text must be a non-empty string.`, {
|
|
184
|
+
filePath, field: `${where}.text`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
if (e.source !== undefined && typeof e.source !== 'string') {
|
|
188
|
+
throw new LedgerError(`brief ${where}.source must be a string when present.`, {
|
|
189
|
+
filePath, field: `${where}.source`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
} else if (name === 'decisions') {
|
|
193
|
+
if (typeof e.question !== 'string' || e.question.length === 0) {
|
|
194
|
+
throw new LedgerError(`decisions ${where}.question must be non-empty.`, {
|
|
195
|
+
filePath, field: `${where}.question`,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (!Array.isArray(e.options) || e.options.length === 0) {
|
|
199
|
+
throw new LedgerError(`decisions ${where}.options must be a non-empty array.`, {
|
|
200
|
+
filePath, field: `${where}.options`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
for (let i = 0; i < e.options.length; i += 1) {
|
|
204
|
+
const opt = e.options[i];
|
|
205
|
+
if (
|
|
206
|
+
opt === null || typeof opt !== 'object' || Array.isArray(opt)
|
|
207
|
+
|| typeof (/** @type {any} */ (opt)).letter !== 'string'
|
|
208
|
+
|| typeof (/** @type {any} */ (opt)).text !== 'string'
|
|
209
|
+
) {
|
|
210
|
+
throw new LedgerError(
|
|
211
|
+
`decisions ${where}.options[${i}] must be { letter, text }.`,
|
|
212
|
+
{ filePath, field: `${where}.options[${i}]` },
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (e.default !== null && e.default !== undefined && typeof e.default !== 'string') {
|
|
217
|
+
throw new LedgerError(
|
|
218
|
+
`decisions ${where}.default must be a letter string or null.`,
|
|
219
|
+
{ filePath, field: `${where}.default` },
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (e.blocks !== undefined && typeof e.blocks !== 'string') {
|
|
223
|
+
throw new LedgerError(`decisions ${where}.blocks must be a string when present.`, {
|
|
224
|
+
filePath, field: `${where}.blocks`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
} else if (name === 'concerns') {
|
|
228
|
+
if (typeof e.concern !== 'string' || e.concern.length === 0) {
|
|
229
|
+
throw new LedgerError(`concerns ${where}.concern must be non-empty.`, {
|
|
230
|
+
filePath, field: `${where}.concern`,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (typeof e.reqId !== 'string' || e.reqId.length === 0) {
|
|
234
|
+
throw new LedgerError(`concerns ${where}.reqId must be non-empty.`, {
|
|
235
|
+
filePath, field: `${where}.reqId`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (
|
|
239
|
+
typeof e.disposition !== 'string'
|
|
240
|
+
|| !CONCERN_DISPOSITIONS.includes(/** @type {any} */ (e.disposition))
|
|
241
|
+
) {
|
|
242
|
+
throw new LedgerError(
|
|
243
|
+
`concerns ${where}.disposition must be one of: ${CONCERN_DISPOSITIONS.join(', ')}.`,
|
|
244
|
+
{ filePath, field: `${where}.disposition` },
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
} else if (name === 'probes') {
|
|
248
|
+
if (typeof e.reqId !== 'string' || e.reqId.length === 0) {
|
|
249
|
+
throw new LedgerError(`probes ${where}.reqId must be non-empty.`, {
|
|
250
|
+
filePath, field: `${where}.reqId`,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (typeof e.finding !== 'string' || e.finding.length === 0) {
|
|
254
|
+
throw new LedgerError(`probes ${where}.finding must be non-empty.`, {
|
|
255
|
+
filePath, field: `${where}.finding`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (
|
|
259
|
+
typeof e.severity !== 'string'
|
|
260
|
+
|| !PROBE_SEVERITIES.includes(/** @type {any} */ (e.severity))
|
|
261
|
+
) {
|
|
262
|
+
throw new LedgerError(
|
|
263
|
+
`probes ${where}.severity must be one of: ${PROBE_SEVERITIES.join(', ')}.`,
|
|
264
|
+
{ filePath, field: `${where}.severity` },
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Validate a full ledger body (the shape a file holds).
|
|
272
|
+
*
|
|
273
|
+
* @param {LedgerName} name
|
|
274
|
+
* @param {unknown} raw
|
|
275
|
+
* @param {string} [filePath]
|
|
276
|
+
* @returns {Record<string, unknown[]>}
|
|
277
|
+
*/
|
|
278
|
+
export function validateLedger(name, raw, filePath) {
|
|
279
|
+
const cfg = LEDGER_CONFIG[name];
|
|
280
|
+
if (!cfg) throw new LedgerError(`Unknown ledger '${name}'.`, { filePath, code: 'usage' });
|
|
281
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
282
|
+
throw new LedgerError(`${name} ledger must be a JSON object.`, {
|
|
283
|
+
filePath, field: '(root)',
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
const body = /** @type {Record<string, unknown>} */ (raw);
|
|
287
|
+
const list = body[cfg.arrayKey];
|
|
288
|
+
if (!Array.isArray(list)) {
|
|
289
|
+
throw new LedgerError(
|
|
290
|
+
`${name} ledger '${cfg.arrayKey}' must be an array.`,
|
|
291
|
+
{ filePath, field: cfg.arrayKey },
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
const seen = new Set();
|
|
295
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
296
|
+
const entry = list[i];
|
|
297
|
+
validateEntry(name, entry, filePath, `${cfg.arrayKey}[${i}]`);
|
|
298
|
+
const id = /** @type {any} */ (entry).id;
|
|
299
|
+
if (seen.has(id)) {
|
|
300
|
+
throw new LedgerError(
|
|
301
|
+
`${name} ledger has duplicate id ${id}.`,
|
|
302
|
+
{ filePath, field: `${cfg.arrayKey}[${i}].id` },
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
seen.add(id);
|
|
306
|
+
}
|
|
307
|
+
return /** @type {Record<string, unknown[]>} */ (body);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Load a ledger. Returns the empty ledger when the file is absent;
|
|
312
|
+
* throws `LedgerError` on parse / schema failure.
|
|
313
|
+
*
|
|
314
|
+
* @param {object} args
|
|
315
|
+
* @param {string} args.projectRoot
|
|
316
|
+
* @param {LedgerName} args.name
|
|
317
|
+
* @returns {Promise<Record<string, unknown[]>>}
|
|
318
|
+
*/
|
|
319
|
+
export async function loadLedger({ projectRoot, name }) {
|
|
320
|
+
const relPath = ledgerRelPath(name);
|
|
321
|
+
const filePath = ledgerPath(projectRoot, name);
|
|
322
|
+
let raw;
|
|
323
|
+
try {
|
|
324
|
+
raw = await readFile(filePath, 'utf8');
|
|
325
|
+
} catch (err) {
|
|
326
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') {
|
|
327
|
+
return emptyLedger(name);
|
|
328
|
+
}
|
|
329
|
+
throw new LedgerError(
|
|
330
|
+
`Failed to read ${relPath}: ${/** @type {Error} */ (err).message}`,
|
|
331
|
+
{ filePath: relPath, code: 'ioFailure' },
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
let parsed;
|
|
335
|
+
try {
|
|
336
|
+
parsed = JSON.parse(raw);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
throw new LedgerError(
|
|
339
|
+
`${relPath} parse failed: ${/** @type {Error} */ (err).message}`,
|
|
340
|
+
{ filePath: relPath, code: 'parseFailure' },
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
return validateLedger(name, parsed, relPath);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Save a ledger, validating first.
|
|
348
|
+
*
|
|
349
|
+
* @param {object} args
|
|
350
|
+
* @param {string} args.projectRoot
|
|
351
|
+
* @param {LedgerName} args.name
|
|
352
|
+
* @param {Record<string, unknown>} args.body
|
|
353
|
+
* @returns {Promise<{ filePath: string }>}
|
|
354
|
+
*/
|
|
355
|
+
export async function saveLedger({ projectRoot, name, body }) {
|
|
356
|
+
const validated = validateLedger(name, body, ledgerRelPath(name));
|
|
357
|
+
const filePath = ledgerPath(projectRoot, name);
|
|
358
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
359
|
+
await writeFile(filePath, `${JSON.stringify(validated, null, 2)}\n`, 'utf8');
|
|
360
|
+
return { filePath };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Highest currently-used id on a ledger; 0 when empty.
|
|
365
|
+
*
|
|
366
|
+
* @param {LedgerName} name
|
|
367
|
+
* @param {Record<string, unknown[]>} body
|
|
368
|
+
* @returns {number}
|
|
369
|
+
*/
|
|
370
|
+
export function nextIdFor(name, body) {
|
|
371
|
+
const cfg = LEDGER_CONFIG[name];
|
|
372
|
+
if (!cfg) throw new TypeError(`Unknown ledger '${name}'.`);
|
|
373
|
+
const list = /** @type {any[]} */ (body[cfg.arrayKey] ?? []);
|
|
374
|
+
let max = 0;
|
|
375
|
+
for (const e of list) {
|
|
376
|
+
const id = Number(e?.id);
|
|
377
|
+
if (Number.isFinite(id) && id > max) max = id;
|
|
378
|
+
}
|
|
379
|
+
return max + 1;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Append an entry to a ledger. Mints the next id, stamps `addedAt`,
|
|
384
|
+
* defaults `status: 'open'`, and validates before returning the
|
|
385
|
+
* updated body. Pure over its inputs (does not write to disk); the
|
|
386
|
+
* CLI calls `saveLedger` with the result.
|
|
387
|
+
*
|
|
388
|
+
* @param {object} args
|
|
389
|
+
* @param {LedgerName} args.name
|
|
390
|
+
* @param {Record<string, unknown[]>} args.body
|
|
391
|
+
* @param {Record<string, unknown>} args.entry - fields other than id / addedAt / status
|
|
392
|
+
* @param {string} [args.now] - ISO timestamp; defaults to Date.now()
|
|
393
|
+
* @returns {{ body: Record<string, unknown[]>, entry: Record<string, unknown> }}
|
|
394
|
+
*/
|
|
395
|
+
export function addEntry({ name, body, entry, now }) {
|
|
396
|
+
const cfg = LEDGER_CONFIG[name];
|
|
397
|
+
if (!cfg) throw new LedgerError(`Unknown ledger '${name}'.`, { code: 'usage' });
|
|
398
|
+
const id = nextIdFor(name, body);
|
|
399
|
+
const full = {
|
|
400
|
+
id,
|
|
401
|
+
...entry,
|
|
402
|
+
addedAt: entry.addedAt ?? now ?? new Date().toISOString(),
|
|
403
|
+
status: entry.status ?? 'open',
|
|
404
|
+
};
|
|
405
|
+
const nextBody = { ...body, [cfg.arrayKey]: [...(body[cfg.arrayKey] ?? []), full] };
|
|
406
|
+
validateLedger(name, nextBody, ledgerRelPath(name));
|
|
407
|
+
return { body: nextBody, entry: full };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Mark an entry `resolved` and stamp `resolvedAt`. Throws `LedgerError`
|
|
412
|
+
* (code `usage`) when the id is unknown. Idempotent-ish: resolving an
|
|
413
|
+
* already-resolved entry refreshes `resolvedAt`.
|
|
414
|
+
*
|
|
415
|
+
* @param {object} args
|
|
416
|
+
* @param {LedgerName} args.name
|
|
417
|
+
* @param {Record<string, unknown[]>} args.body
|
|
418
|
+
* @param {number} args.id
|
|
419
|
+
* @param {Record<string, unknown>} [args.patch] - extra fields (e.g. answer, resolvedBy)
|
|
420
|
+
* @param {string} [args.now]
|
|
421
|
+
* @returns {{ body: Record<string, unknown[]>, entry: Record<string, unknown> }}
|
|
422
|
+
*/
|
|
423
|
+
export function resolveEntry({ name, body, id, patch = {}, now }) {
|
|
424
|
+
const cfg = LEDGER_CONFIG[name];
|
|
425
|
+
if (!cfg) throw new LedgerError(`Unknown ledger '${name}'.`, { code: 'usage' });
|
|
426
|
+
const list = /** @type {any[]} */ (body[cfg.arrayKey] ?? []);
|
|
427
|
+
const idx = list.findIndex((e) => Number(e?.id) === Number(id));
|
|
428
|
+
if (idx < 0) {
|
|
429
|
+
throw new LedgerError(`${name} ledger has no entry with id ${id}.`, {
|
|
430
|
+
filePath: ledgerRelPath(name), field: `id ${id}`, code: 'usage',
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
const updated = {
|
|
434
|
+
...list[idx],
|
|
435
|
+
...patch,
|
|
436
|
+
status: 'resolved',
|
|
437
|
+
resolvedAt: patch.resolvedAt ?? now ?? new Date().toISOString(),
|
|
438
|
+
};
|
|
439
|
+
const nextList = [...list];
|
|
440
|
+
nextList[idx] = updated;
|
|
441
|
+
const nextBody = { ...body, [cfg.arrayKey]: nextList };
|
|
442
|
+
validateLedger(name, nextBody, ledgerRelPath(name));
|
|
443
|
+
return { body: nextBody, entry: updated };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Parse `--from <file>` content into brief-statement drafts: one entry
|
|
448
|
+
* per non-empty line, stripping a leading `- `, `* ` or `<n>. ` bullet.
|
|
449
|
+
* The caller supplies the `kind` (or `capability` as the default). All
|
|
450
|
+
* statements land as `status: 'open'` at the same `addedAt`; the CLI
|
|
451
|
+
* assigns sequential ids.
|
|
452
|
+
*
|
|
453
|
+
* @param {string} content
|
|
454
|
+
* @param {object} [opts]
|
|
455
|
+
* @param {string} [opts.kind] default kind for every extracted line
|
|
456
|
+
* @param {string} [opts.source] optional source-span label per entry
|
|
457
|
+
* @returns {Array<{ text: string, kind: string, source?: string }>}
|
|
458
|
+
*/
|
|
459
|
+
export function parseBriefFromFile(content, opts = {}) {
|
|
460
|
+
const kind = opts.kind ?? 'capability';
|
|
461
|
+
/** @type {Array<{ text: string, kind: string, source?: string }>} */
|
|
462
|
+
const drafts = [];
|
|
463
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
464
|
+
const trimmed = raw.trim();
|
|
465
|
+
if (!trimmed) continue;
|
|
466
|
+
const stripped = trimmed
|
|
467
|
+
.replace(/^[-*]\s+/, '')
|
|
468
|
+
.replace(/^\d+[.)]\s+/, '');
|
|
469
|
+
if (!stripped) continue;
|
|
470
|
+
const entry = { text: stripped, kind };
|
|
471
|
+
if (opts.source) entry.source = opts.source;
|
|
472
|
+
drafts.push(entry);
|
|
473
|
+
}
|
|
474
|
+
return drafts;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Load every ledger the project has actually authored, keyed by name.
|
|
479
|
+
*
|
|
480
|
+
* A project that has never authored a given ledger contributes no
|
|
481
|
+
* `<name>` entry to the returned bundle, so `computeDelta` does not
|
|
482
|
+
* record a `ledger:<name>` docHash for absent ledgers (slice-2 P3
|
|
483
|
+
* resolution: this loader agrees with the `LedgerBundle` typedef on
|
|
484
|
+
* `computeDelta` that "if a project has not authored any decisions,
|
|
485
|
+
* the caller passes no `decisions` key and the delta records no
|
|
486
|
+
* `ledger:decisions` docHash"). Absent-file detection is a `stat` on
|
|
487
|
+
* the ledger path; an ENOENT skips the ledger without loading. A
|
|
488
|
+
* present-but-empty file is still loaded (an operator can `rcf
|
|
489
|
+
* define ledger <name> list --json > <path>` a stub in place; empty
|
|
490
|
+
* ledgers are legal and DO contribute a hash).
|
|
491
|
+
*
|
|
492
|
+
* @param {object} args
|
|
493
|
+
* @param {string} args.projectRoot
|
|
494
|
+
* @returns {Promise<import('../query/delta.js').LedgerBundle>}
|
|
495
|
+
*/
|
|
496
|
+
export async function loadAllLedgers({ projectRoot }) {
|
|
497
|
+
/** @type {import('../query/delta.js').LedgerBundle} */
|
|
498
|
+
const bundle = {};
|
|
499
|
+
for (const name of LEDGER_NAMES) {
|
|
500
|
+
const filePath = ledgerPath(projectRoot, name);
|
|
501
|
+
try {
|
|
502
|
+
// eslint-disable-next-line no-await-in-loop
|
|
503
|
+
await stat(filePath);
|
|
504
|
+
} catch (err) {
|
|
505
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') continue;
|
|
506
|
+
// Any other stat error (permission etc.) surfaces via loadLedger.
|
|
507
|
+
}
|
|
508
|
+
// eslint-disable-next-line no-await-in-loop
|
|
509
|
+
const body = await loadLedger({ projectRoot, name });
|
|
510
|
+
bundle[name] = body;
|
|
511
|
+
}
|
|
512
|
+
return bundle;
|
|
513
|
+
}
|
package/src/feedback/gh.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
// - ghLabelList({ repo }) list existing label names
|
|
6
6
|
// - ghRepoView({ repo }) visibility + viewerPermission + hasIssuesEnabled
|
|
7
7
|
// - ghIssueSearch({ repo, query, state, limit }) dedupe search
|
|
8
|
+
// - ghIssueGetBody({ repo, number }) re-read a candidate body for fingerprint verification (0.28.2 / #234)
|
|
8
9
|
// - ghIssueCreate({ repo, title, body, labels }) create one issue
|
|
9
|
-
// - ghIssueComment({ repo, number, body }) +1
|
|
10
|
+
// - ghIssueComment({ repo, number, body }) fold comment (carries the full report; the "+1 from another reporter" payload retired in 0.28.2 per Barry's 2026-09-21 ruling on #234)
|
|
10
11
|
// - ghLabelCreate({ repo, name, description, color }) bootstrap only
|
|
11
12
|
//
|
|
12
13
|
// Every function returns a typed result. Errors are classified into
|
|
@@ -68,6 +69,7 @@ export async function loadGhAdapter(env = process.env) {
|
|
|
68
69
|
* @property {(opts: { repo: string }) => Promise<GhResult>} ghLabelList
|
|
69
70
|
* @property {(opts: { repo: string }) => Promise<GhResult>} ghRepoView
|
|
70
71
|
* @property {(opts: { repo: string, query: string, state?: 'open' | 'closed', limit?: number }) => Promise<GhResult>} ghIssueSearch
|
|
72
|
+
* @property {(opts: { repo: string, number: number }) => Promise<GhResult>} ghIssueGetBody
|
|
71
73
|
* @property {(opts: { repo: string, title: string, body: string, labels: string[] }) => Promise<GhResult>} ghIssueCreate
|
|
72
74
|
* @property {(opts: { repo: string, number: number, body: string }) => Promise<GhResult>} ghIssueComment
|
|
73
75
|
* @property {(opts: { repo: string, name: string, description?: string, color?: string }) => Promise<GhResult>} ghLabelCreate
|
|
@@ -189,6 +191,29 @@ export async function ghIssueSearch(opts) {
|
|
|
189
191
|
}
|
|
190
192
|
}
|
|
191
193
|
|
|
194
|
+
/**
|
|
195
|
+
* `gh issue view <n> --repo <repo> --json body`. Re-reads the body of
|
|
196
|
+
* a candidate issue for the fingerprint-verification step introduced
|
|
197
|
+
* in 0.28.2 (issue #234): a search hit whose body does not carry the
|
|
198
|
+
* fingerprint line on its own line is a non-match and is discarded
|
|
199
|
+
* before the fold decision. Returns { body }.
|
|
200
|
+
*
|
|
201
|
+
* @param {{ repo: string, number: number }} opts
|
|
202
|
+
* @returns {Promise<GhResult>}
|
|
203
|
+
*/
|
|
204
|
+
export async function ghIssueGetBody(opts) {
|
|
205
|
+
const args = ['issue', 'view', String(opts.number), '--repo', opts.repo, '--json', 'body'];
|
|
206
|
+
const r = await runGh(args);
|
|
207
|
+
if (!r.ok) return r;
|
|
208
|
+
try {
|
|
209
|
+
const parsed = JSON.parse(r.value.stdout);
|
|
210
|
+
const body = typeof parsed?.body === 'string' ? parsed.body : '';
|
|
211
|
+
return { ok: true, value: { body } };
|
|
212
|
+
} catch (err) {
|
|
213
|
+
return { ok: false, kind: 'unknown', message: `issue-view parse failed: ${err.message}` };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
192
217
|
/**
|
|
193
218
|
* `gh issue create --repo <repo> --title <t> --body-file - --label
|
|
194
219
|
* <l1> --label <l2>...` reads the body from stdin so a long body is
|
|
@@ -370,6 +395,7 @@ export const DEFAULT_ADAPTER = Object.freeze({
|
|
|
370
395
|
ghLabelList,
|
|
371
396
|
ghRepoView,
|
|
372
397
|
ghIssueSearch,
|
|
398
|
+
ghIssueGetBody,
|
|
373
399
|
ghIssueCreate,
|
|
374
400
|
ghIssueComment,
|
|
375
401
|
ghLabelCreate,
|