agentic-workflow-manager 9.19.0 → 9.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/src/commands/change/index.js +492 -0
  2. package/dist/src/commands/changes/index.js +84 -0
  3. package/dist/src/commands/sensors/index.js +9 -0
  4. package/dist/src/commands/sensors/run.js +1 -0
  5. package/dist/src/core/changes/command.js +38 -0
  6. package/dist/src/core/changes/derive.js +120 -0
  7. package/dist/src/core/changes/fs-safe.js +47 -0
  8. package/dist/src/core/changes/git-env.js +68 -0
  9. package/dist/src/core/changes/git.js +216 -0
  10. package/dist/src/core/changes/list-parse.js +67 -0
  11. package/dist/src/core/changes/list.js +408 -0
  12. package/dist/src/core/changes/parse.js +72 -0
  13. package/dist/src/core/changes/range.js +53 -0
  14. package/dist/src/core/changes/receipt.js +274 -0
  15. package/dist/src/core/changes/record-io.js +149 -0
  16. package/dist/src/core/changes/render-list.js +109 -0
  17. package/dist/src/core/changes/render.js +178 -0
  18. package/dist/src/core/changes/text-safety.js +116 -0
  19. package/dist/src/core/changes/trailers.js +76 -0
  20. package/dist/src/core/changes/types.js +26 -0
  21. package/dist/src/core/changes/validate-shape.js +219 -0
  22. package/dist/src/core/changes/worktree.js +53 -0
  23. package/dist/src/index.js +4 -0
  24. package/dist/src/release/orchestrator.js +3 -1
  25. package/dist/tests/commands/change/new.test.js +1126 -0
  26. package/dist/tests/commands/change/validate.test.js +1145 -0
  27. package/dist/tests/commands/changes/list.test.js +423 -0
  28. package/dist/tests/commands/sensors/index.test.js +1 -1
  29. package/dist/tests/core/changes/derive.test.js +455 -0
  30. package/dist/tests/core/changes/fs-safe.test.js +82 -0
  31. package/dist/tests/core/changes/git-env.test.js +143 -0
  32. package/dist/tests/core/changes/git.test.js +442 -0
  33. package/dist/tests/core/changes/helpers.js +269 -0
  34. package/dist/tests/core/changes/list-fixtures.js +122 -0
  35. package/dist/tests/core/changes/list-long-history.test.js +52 -0
  36. package/dist/tests/core/changes/list-paging.test.js +511 -0
  37. package/dist/tests/core/changes/list-parse.test.js +120 -0
  38. package/dist/tests/core/changes/list-tree.test.js +44 -0
  39. package/dist/tests/core/changes/list.test.js +821 -0
  40. package/dist/tests/core/changes/parse.test.js +142 -0
  41. package/dist/tests/core/changes/predicates.test.js +83 -0
  42. package/dist/tests/core/changes/range.test.js +92 -0
  43. package/dist/tests/core/changes/receipt.test.js +947 -0
  44. package/dist/tests/core/changes/record-io.test.js +307 -0
  45. package/dist/tests/core/changes/record.test.js +549 -0
  46. package/dist/tests/core/changes/render-list.test.js +212 -0
  47. package/dist/tests/core/changes/render.test.js +202 -0
  48. package/dist/tests/core/changes/text-safety.test.js +226 -0
  49. package/dist/tests/core/changes/trailers.test.js +181 -0
  50. package/dist/tests/core/changes/worktree.test.js +259 -0
  51. package/dist/tests/release/orchestrator-trailer.test.js +72 -0
  52. package/dist/tests/release/orchestrator.test.js +1 -0
  53. package/dist/tests/structural/change-commands-are-registered.test.js +29 -0
  54. package/dist/tests/structural/change-record-git-and-platform-conventions.test.js +77 -0
  55. package/package.json +1 -1
  56. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  57. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  58. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  59. package/prebuilds/win32-x64/secure_fs.node +0 -0
@@ -0,0 +1,274 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.isFullSelection = isFullSelection;
7
+ exports.sensorPairs = sensorPairs;
8
+ exports.computeReceiptDigest = computeReceiptDigest;
9
+ exports.buildReceipt = buildReceipt;
10
+ exports.writeReceipt = writeReceipt;
11
+ exports.readReceipt = readReceipt;
12
+ exports.captureReceiptPrecondition = captureReceiptPrecondition;
13
+ exports.finalizeReceipt = finalizeReceipt;
14
+ exports.isRootManifest = isRootManifest;
15
+ exports.finalizeRunReceipt = finalizeRunReceipt;
16
+ const fs_1 = __importDefault(require("fs"));
17
+ const path_1 = __importDefault(require("path"));
18
+ const crypto_1 = __importDefault(require("crypto"));
19
+ const atomic_file_1 = require("../atomic-file");
20
+ const cli_version_1 = require("../cli-version");
21
+ const paths_1 = require("../paths");
22
+ const types_1 = require("./types");
23
+ const text_safety_1 = require("./text-safety");
24
+ const worktree_1 = require("./worktree");
25
+ const RECEIPT_KEYS = ['schema', 'commit', 'ranAt', 'cliVersion', 'overall', 'sensors', 'digest'];
26
+ const MAX_RECEIPT_BYTES = 1024 * 1024;
27
+ /** True when the run covers every sensor: `--changed` never does, `--all` always does, `--fast` xor `--slow` is partial. */
28
+ function isFullSelection(opts) {
29
+ if (opts === null || typeof opts !== 'object')
30
+ return false;
31
+ if (opts.changed)
32
+ return false;
33
+ if (opts.all)
34
+ return true;
35
+ return Boolean(opts.fast) === Boolean(opts.slow);
36
+ }
37
+ const byName = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
38
+ /**
39
+ * The sensors of a receipt or a record as [name, status] pairs sorted by name in UTF-16 code-unit order. JavaScript orders the keys
40
+ * of an object by itself (integer-like names such as '9' and '10' come first, numerically), so nothing that is digested or
41
+ * compared relies on that order: it goes through this array.
42
+ */
43
+ function sensorPairs(sensors) {
44
+ return Object.entries(sensors).sort(([a], [b]) => byName(a, b));
45
+ }
46
+ function sortedSensors(sensors) {
47
+ return Object.fromEntries(sensorPairs(sensors));
48
+ }
49
+ /**
50
+ * sha256 over the canonical JSON of the receipt without its digest, `sensors` being the name-sorted pairs of sensorPairs (R2.2c).
51
+ * The digest is local (receipts are not versioned and are rewritten by every full run), so changing its canonical form only
52
+ * invalidates receipts written before it; `change new` writes and `change validate` reads the same value.
53
+ */
54
+ function computeReceiptDigest(body) {
55
+ if (body === null || typeof body !== 'object' || Array.isArray(body))
56
+ throw new Error((0, text_safety_1.esc)('invalid receipt: digest body must be an object'));
57
+ if (body.sensors === null || typeof body.sensors !== 'object' || Array.isArray(body.sensors))
58
+ throw new Error((0, text_safety_1.esc)('invalid receipt: digest body sensors must be an object'));
59
+ const canonical = JSON.stringify({
60
+ schema: body.schema, commit: body.commit, ranAt: body.ranAt, cliVersion: body.cliVersion,
61
+ overall: body.overall, sensors: sensorPairs(body.sensors),
62
+ });
63
+ return 'sha256:' + crypto_1.default.createHash('sha256').update(canonical).digest('hex');
64
+ }
65
+ /**
66
+ * The ONE receipt shape check, shared by build, write and read so that nothing is written that cannot be read back.
67
+ * Returns a static description of the first problem (never echoes the offending value), or null when the receipt is pristine.
68
+ */
69
+ function receiptProblem(x) {
70
+ if (x === null || typeof x !== 'object' || Array.isArray(x))
71
+ return 'receipt must be an object';
72
+ const r = x;
73
+ const keys = Object.keys(r);
74
+ if (keys.length !== RECEIPT_KEYS.length || !RECEIPT_KEYS.every((k) => k in r))
75
+ return 'receipt must have exactly the schema keys';
76
+ if (r.schema !== 1)
77
+ return 'schema must be 1';
78
+ if (!(0, types_1.isCommitId)(r.commit))
79
+ return 'commit must be 40 or 64 lowercase hex characters';
80
+ if (!(0, text_safety_1.isCanonicalIso)(r.ranAt))
81
+ return 'ranAt must be a canonical ISO timestamp';
82
+ if (!(0, text_safety_1.isSingleLine)(r.cliVersion))
83
+ return 'cliVersion must be a single line';
84
+ if (!(0, types_1.isOverall)(r.overall))
85
+ return 'overall is not a known verdict';
86
+ const sensors = r.sensors;
87
+ if (sensors === null || typeof sensors !== 'object' || Array.isArray(sensors))
88
+ return 'sensors must be an object';
89
+ for (const [name, status] of Object.entries(sensors)) {
90
+ if (!(0, types_1.isSensorName)(name))
91
+ return `sensor names must be a single line of 1 to ${types_1.MAX_SENSOR_NAME} characters`;
92
+ if (!(0, types_1.isSensorStatus)(status))
93
+ return 'a sensor status is not a known status';
94
+ }
95
+ if (typeof r.digest !== 'string')
96
+ return 'digest must be a string';
97
+ const { digest, ...body } = r;
98
+ if (computeReceiptDigest(body) !== digest)
99
+ return 'digest does not match the content';
100
+ return null;
101
+ }
102
+ /** Builds a receipt and fails closed: throws an Error naming the problem when the result is not one `readReceipt` would accept. */
103
+ function buildReceipt(input) {
104
+ const out = input === null || typeof input !== 'object' ? undefined : input.output;
105
+ if (out === null || typeof out !== 'object' || !Array.isArray(out.sensors) || out.sensors.some((s) => s === null || typeof s !== 'object')) {
106
+ throw new Error('invalid receipt: run output must carry a sensors array');
107
+ }
108
+ const sensors = sortedSensors(Object.fromEntries(out.sensors.map((s) => [s.name, s.status])));
109
+ const body = { schema: 1, commit: input.commit, ranAt: input.ranAt, cliVersion: input.cliVersion, overall: out.overall, sensors };
110
+ const receipt = { ...body, digest: computeReceiptDigest(body) };
111
+ const problem = receiptProblem(receipt);
112
+ if (problem !== null)
113
+ throw new Error(`invalid receipt: ${problem}`);
114
+ return receipt;
115
+ }
116
+ function runsDir(root) {
117
+ return path_1.default.join(root, '.awm', 'sensors', 'runs');
118
+ }
119
+ const RUNS_IGNORE_FILE = '.gitignore';
120
+ const RUNS_IGNORE_CONTENT = '*\n';
121
+ /**
122
+ * R2.2b: receipts are local and never versioned, so the directory ignores itself (`*` also covers this file): `git add -A` in a
123
+ * repository that does not ignore `.awm` can then never commit them. Idempotent; a link at the target is refused (writeFileAtomic).
124
+ */
125
+ function ensureRunsIgnore(root) {
126
+ const file = path_1.default.join(runsDir(root), RUNS_IGNORE_FILE);
127
+ try {
128
+ const stat = fs_1.default.lstatSync(file);
129
+ if (stat.isFile() && fs_1.default.readFileSync(file, 'utf8') === RUNS_IGNORE_CONTENT)
130
+ return;
131
+ }
132
+ catch (error) {
133
+ if (error.code !== 'ENOENT')
134
+ throw error;
135
+ }
136
+ (0, atomic_file_1.writeFileAtomic)(file, RUNS_IGNORE_CONTENT, 0o644);
137
+ }
138
+ function assertNoSymlinkChain(root) {
139
+ let current = root;
140
+ for (const segment of ['.awm', 'sensors', 'runs']) {
141
+ current = path_1.default.join(current, segment);
142
+ try {
143
+ if (fs_1.default.lstatSync(current).isSymbolicLink())
144
+ throw new Error(`refusing to use symlinked path ${(0, text_safety_1.esc)(current)}`);
145
+ }
146
+ catch (error) {
147
+ if (error.code === 'ENOENT')
148
+ return;
149
+ throw error;
150
+ }
151
+ }
152
+ }
153
+ /** Writes a receipt. Validates with the same check `readReceipt` applies BEFORE touching the filesystem; throws an Error (never a raw TypeError). */
154
+ function writeReceipt(root, receipt) {
155
+ if (typeof root !== 'string' || root.length === 0)
156
+ throw new Error('receipt root must be a non-empty string');
157
+ const problem = receiptProblem(receipt);
158
+ if (problem !== null)
159
+ throw new Error(`invalid receipt: ${problem}`);
160
+ const content = JSON.stringify(receipt, null, 2) + '\n';
161
+ if (Buffer.byteLength(content) > MAX_RECEIPT_BYTES)
162
+ throw new Error('invalid receipt: larger than the size readReceipt accepts');
163
+ assertNoSymlinkChain(root);
164
+ fs_1.default.mkdirSync(runsDir(root), { recursive: true, mode: 0o700 });
165
+ assertNoSymlinkChain(root);
166
+ ensureRunsIgnore(root);
167
+ const file = path_1.default.join(runsDir(root), `${receipt.commit}.json`);
168
+ (0, atomic_file_1.writeFileAtomic)(file, content, 0o600);
169
+ // writeFileAtomic keeps the mode of a file it replaces; a receipt is always owner-only.
170
+ fs_1.default.chmodSync(file, 0o600);
171
+ return file;
172
+ }
173
+ const INVALID = { ok: false, code: 'receipt-invalid' };
174
+ /** Reads and verifies a receipt. Anything that is not a pristine, digest-matching receipt for `commit` reads as invalid (or missing). */
175
+ function readReceipt(root, commit) {
176
+ if (typeof root !== 'string' || root.length === 0 || !(0, types_1.isCommitId)(commit))
177
+ return INVALID;
178
+ let raw;
179
+ try {
180
+ assertNoSymlinkChain(root);
181
+ const file = path_1.default.join(runsDir(root), `${commit}.json`);
182
+ const stat = fs_1.default.lstatSync(file);
183
+ // lstat does not follow links, so a symlink is simply "not a file".
184
+ if (!stat.isFile() || stat.size > MAX_RECEIPT_BYTES)
185
+ return INVALID;
186
+ raw = fs_1.default.readFileSync(file, 'utf8');
187
+ }
188
+ catch (error) {
189
+ return error.code === 'ENOENT' ? { ok: false, code: 'receipt-missing' } : INVALID;
190
+ }
191
+ let x;
192
+ try {
193
+ x = JSON.parse(raw);
194
+ }
195
+ catch {
196
+ return INVALID;
197
+ }
198
+ if (receiptProblem(x) !== null || x.commit !== commit)
199
+ return INVALID;
200
+ const receipt = x;
201
+ return { ok: true, receipt };
202
+ }
203
+ /** Measured BEFORE the run: a receipt certifies a commit only when the tree equals it. */
204
+ function captureReceiptPrecondition(cwd) {
205
+ if (typeof cwd !== 'string' || cwd.length === 0)
206
+ return { eligible: false, reason: 'cwd must be a non-empty string' };
207
+ const state = (0, worktree_1.gitState)(cwd, true);
208
+ if (state.kind === 'not-repo')
209
+ return { eligible: false, reason: worktree_1.REASON_NOT_REPO };
210
+ if (state.kind === 'failed')
211
+ return { eligible: false, reason: worktree_1.REASON_GIT_FAILED };
212
+ if (state.dirty)
213
+ return { eligible: false, reason: 'working tree has uncommitted changes (commit them first)' };
214
+ if (!(0, types_1.isCommitId)(state.commit))
215
+ return { eligible: false, reason: 'HEAD is not a full commit id' };
216
+ return { eligible: true, root: state.root, commit: state.commit };
217
+ }
218
+ /** Returns '' on success, or one stderr line explaining why no receipt was written. Never throws. */
219
+ function finalizeReceipt(pre, output, deps = {}) {
220
+ try {
221
+ if (pre === null || typeof pre !== 'object')
222
+ return 'no sensor receipt written: precondition was not captured\n';
223
+ if (!pre.eligible)
224
+ return `no sensor receipt written: ${(0, text_safety_1.esc)(pre.reason)}\n`;
225
+ const after = (0, worktree_1.gitState)(pre.root, false);
226
+ if (after.kind === 'failed')
227
+ return `no sensor receipt written: ${worktree_1.REASON_GIT_FAILED}\n`;
228
+ if (after.kind === 'not-repo')
229
+ return `no sensor receipt written: ${worktree_1.REASON_NOT_REPO}\n`;
230
+ if (after.commit !== pre.commit || after.dirty)
231
+ return 'no sensor receipt written: HEAD or tracked files changed during the run\n';
232
+ writeReceipt(pre.root, buildReceipt({ commit: pre.commit, ranAt: (deps.now ?? (() => new Date()))().toISOString(), cliVersion: (deps.version ?? cli_version_1.cliVersion)(), output }));
233
+ return '';
234
+ }
235
+ catch (error) {
236
+ return `no sensor receipt written: ${(0, text_safety_1.esc)(error instanceof Error ? error.message : String(error))}\n`;
237
+ }
238
+ }
239
+ /** True when `manifestPath` is the repository root's `.awm/sensors.json` (identity through realpath.native, see sameExistingPath; a missing file is not it). */
240
+ function isRootManifest(root, manifestPath) {
241
+ if (typeof root !== 'string' || root.length === 0 || typeof manifestPath !== 'string' || manifestPath.length === 0)
242
+ return false;
243
+ return (0, paths_1.sameExistingPath)(path_1.default.join(root, '.awm', 'sensors.json'), manifestPath);
244
+ }
245
+ /**
246
+ * R2.2b/R2.2c: the receipt of a `sensors run`. On top of finalizeReceipt, a receipt certifies the WHOLE declared sensor set or
247
+ * nothing: none is written when no sensor ran (an empty list), when the run dropped part of the manifest (schema-invalid entries,
248
+ * pack drift), when the verdict is `not_certified`, nor when the manifest that ran is not the repository root's (a receipt is
249
+ * keyed by commit only, so the manifests of a monorepo's packages would overwrite each other). Returns '' on success, or one
250
+ * stderr line that says which case applies. Never throws.
251
+ */
252
+ function finalizeRunReceipt(pre, output, deps = {}) {
253
+ try {
254
+ if (pre === null || typeof pre !== 'object' || !pre.eligible)
255
+ return finalizeReceipt(pre, output, deps);
256
+ const withheld = (why) => `no sensor receipt written: ${why}\n`;
257
+ if (output === null || typeof output !== 'object' || !Array.isArray(output.sensors) || output.sensors.length === 0)
258
+ return withheld('no sensor ran, so there is nothing to certify');
259
+ if (output.sensors.every((sensor) => sensor !== null && typeof sensor === 'object' && sensor.status === 'skipped'))
260
+ return withheld('every sensor was skipped, so the run certified nothing');
261
+ if (Array.isArray(output.invalidEntries) && output.invalidEntries.length > 0)
262
+ return withheld('some sensor entries of the manifest were invalid and skipped, so the verdict does not cover it');
263
+ if (output.packDrift !== undefined && output.packDrift !== null)
264
+ return withheld('the manifest is on the generic fallback while the tree has real stack indicators (pack drift)');
265
+ if (output.overall === 'not_certified')
266
+ return withheld('the verdict was not certified (not_certified)');
267
+ if (!isRootManifest(pre.root, output.manifestPath))
268
+ return withheld("the manifest that ran is not the repository root's .awm/sensors.json (a receipt certifies a whole commit)");
269
+ return finalizeReceipt(pre, output, deps);
270
+ }
271
+ catch (error) {
272
+ return `no sensor receipt written: ${(0, text_safety_1.esc)(error instanceof Error ? error.message : String(error))}\n`;
273
+ }
274
+ }
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.errorCode = void 0;
7
+ exports.inspectRecordDir = inspectRecordDir;
8
+ exports.createRecordDir = createRecordDir;
9
+ exports.recordsWithSlug = recordsWithSlug;
10
+ exports.readRecord = readRecord;
11
+ exports.unchangedSince = unchangedSince;
12
+ exports.createExclusive = createExclusive;
13
+ exports.replaceRecord = replaceRecord;
14
+ const fs_1 = __importDefault(require("fs"));
15
+ const path_1 = __importDefault(require("path"));
16
+ const atomic_file_1 = require("../atomic-file");
17
+ const parse_1 = require("./parse");
18
+ const text_safety_1 = require("./text-safety");
19
+ const types_1 = require("./types");
20
+ // Filesystem access to `docs/changes/`: the one place that decides what is safe to read from and write to. Nothing here throws
21
+ // (failures come back as a value), no link is ever followed on purpose, and text from the filesystem is escaped before it is
22
+ // put into a message.
23
+ const errorCode = (error) => ((0, text_safety_1.isObj)(error) && typeof error.code === 'string' ? error.code : 'unknown');
24
+ exports.errorCode = errorCode;
25
+ /** What one path component is, without following links: a real directory, nothing, or something that must not be used. */
26
+ function classify(target) {
27
+ try {
28
+ const stat = fs_1.default.lstatSync(target);
29
+ return stat.isSymbolicLink() || !stat.isDirectory() ? 'unsafe' : 'dir';
30
+ }
31
+ catch (error) {
32
+ return (0, exports.errorCode)(error) === 'ENOENT' ? 'absent' : 'error';
33
+ }
34
+ }
35
+ /** Walks `docs` and `docs/changes` one level at a time: a link or a non-directory on the way is unsafe. */
36
+ function inspectRecordDir(root) {
37
+ let current = root;
38
+ for (const segment of types_1.RECORD_DIR.split('/')) {
39
+ current = path_1.default.join(current, segment);
40
+ const level = classify(current);
41
+ if (level !== 'dir')
42
+ return level === 'absent' ? { kind: 'absent' } : { kind: level };
43
+ }
44
+ return { kind: 'ok', dir: current };
45
+ }
46
+ /**
47
+ * Creates what is missing, one level at a time, and checks each level right after it was made: a link planted in place of
48
+ * `docs` is seen before `changes` is created inside it, so nothing is ever created through a link.
49
+ */
50
+ function createRecordDir(root) {
51
+ let current = root;
52
+ for (const segment of types_1.RECORD_DIR.split('/')) {
53
+ current = path_1.default.join(current, segment);
54
+ try {
55
+ fs_1.default.mkdirSync(current);
56
+ }
57
+ catch (error) {
58
+ if ((0, exports.errorCode)(error) !== 'EEXIST')
59
+ return { kind: 'error' };
60
+ }
61
+ const level = classify(current);
62
+ if (level !== 'dir')
63
+ return { kind: level === 'absent' ? 'error' : level };
64
+ }
65
+ return { kind: 'ok', dir: current };
66
+ }
67
+ /** Names of the records with this slug, whatever their date (a directory entry named like a record counts). Null when the directory cannot be listed. */
68
+ function recordsWithSlug(dir, slug) {
69
+ try {
70
+ return fs_1.default.readdirSync(dir).filter((name) => parse_1.RECORD_FILE_RE.exec(name)?.[4] === slug);
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ const unreadable = (code, message, extras = {}) => ({ ok: false, code, message, ...extras });
77
+ /**
78
+ * Reads a record for a rewrite: a regular file (not a link), within the record size limit, valid UTF-8 so that it can be written
79
+ * back byte for byte. `ignoreBOM: true` keeps a leading BOM in the text as U+FEFF (the default would strip it, and the rewrite
80
+ * would drop it silently); the record then does not start with `---` and fails as invalid.
81
+ */
82
+ function readRecord(target, name) {
83
+ const label = (0, text_safety_1.esc)(name);
84
+ try {
85
+ const stat = fs_1.default.lstatSync(target);
86
+ if (stat.isSymbolicLink())
87
+ return unreadable('unsafe-path', `${label} is a symlink; refusing to write`);
88
+ if (!stat.isFile())
89
+ return unreadable('record-invalid', `${label} is not a regular file`);
90
+ if (stat.size > types_1.MAX_RECORD_BYTES)
91
+ return unreadable('record-invalid', `${label} is larger than ${types_1.MAX_RECORD_BYTES} bytes`, { tooLarge: true });
92
+ const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(fs_1.default.readFileSync(target));
93
+ return { ok: true, text, snapshot: { size: stat.size, mtimeMs: stat.mtimeMs, ino: stat.ino } };
94
+ }
95
+ catch (error) {
96
+ const code = (0, exports.errorCode)(error);
97
+ return code === 'ERR_ENCODING_INVALID_ENCODED_DATA' ? unreadable('record-invalid', `${label} is not valid UTF-8`) : unreadable('io-failed', `could not read ${label} (${(0, text_safety_1.esc)(code)})`);
98
+ }
99
+ }
100
+ /** True only when the path is still a regular file (not a link) with the size, modification time and inode it had when it was read. */
101
+ function unchangedSince(target, snapshot) {
102
+ try {
103
+ const stat = fs_1.default.lstatSync(target);
104
+ if (!stat.isFile() || stat.size !== snapshot.size || stat.mtimeMs !== snapshot.mtimeMs)
105
+ return false;
106
+ // An inode number can be 0 or imprecise on some Windows file systems: compare only when both sides carry one.
107
+ return stat.ino === 0 || snapshot.ino === 0 || stat.ino === snapshot.ino;
108
+ }
109
+ catch {
110
+ return false;
111
+ }
112
+ }
113
+ /** Exclusive create: never replaces a file, never follows a link at the target. Removes what it created when the write fails. */
114
+ function createExclusive(file, content) {
115
+ let fd;
116
+ try {
117
+ fd = fs_1.default.openSync(file, 'wx', 0o644);
118
+ }
119
+ catch (error) {
120
+ return (0, exports.errorCode)(error) === 'EEXIST' ? 'exists' : 'error';
121
+ }
122
+ try {
123
+ fs_1.default.writeFileSync(fd, content, 'utf8');
124
+ fs_1.default.fsyncSync(fd);
125
+ fs_1.default.closeSync(fd);
126
+ return 'ok';
127
+ }
128
+ catch {
129
+ try {
130
+ fs_1.default.closeSync(fd);
131
+ }
132
+ catch { /* the write error is the one that matters */ }
133
+ try {
134
+ fs_1.default.unlinkSync(file);
135
+ }
136
+ catch { /* best effort: the file is ours, and incomplete */ }
137
+ return 'error';
138
+ }
139
+ }
140
+ /** Atomic replacement of an existing record (temporary file + rename, mode kept; a link at the target is refused). */
141
+ function replaceRecord(target, content) {
142
+ try {
143
+ (0, atomic_file_1.writeFileAtomic)(target, content, 0o644);
144
+ return { ok: true };
145
+ }
146
+ catch (error) {
147
+ return { ok: false, code: (0, exports.errorCode)(error) };
148
+ }
149
+ }
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LIST_SCHEMA = void 0;
4
+ exports.renderJson = renderJson;
5
+ exports.renderText = renderText;
6
+ exports.renderVoice = renderVoice;
7
+ const list_parse_1 = require("./list-parse");
8
+ const text_safety_1 = require("./text-safety");
9
+ // The three renderings of `awm changes list` (R5.6). The result already holds clean values (list.ts escapes what git printed);
10
+ // the text and voice lines are cleaned again here, so each stays one line whatever a caller hands in. Nothing here throws.
11
+ const INVALID_TEXT = 'invalid-input: not a list result\n';
12
+ const MAX_VOICE_LINE = 600;
13
+ const MAX_TITLE = 200;
14
+ /** R5.6: the first key of the JSON, so a consumer can tell which contract it is reading. */
15
+ exports.LIST_SCHEMA = 'awm.changes-list/v1';
16
+ const usable = (r) => (0, text_safety_1.isObj)(r) && Array.isArray(r.changes) && typeof r.base === 'string' && typeof r.since === 'string';
17
+ const count = (n) => (typeof n === 'number' && Number.isSafeInteger(n) && n >= 0 ? n : 0);
18
+ const isValid = (r) => (0, text_safety_1.isObj)(r) && !('diagnostics' in r);
19
+ const clipVoice = (line) => (line.length <= MAX_VOICE_LINE ? line : `${(0, text_safety_1.sliceNoSplit)(line, MAX_VOICE_LINE - 1)}…`);
20
+ /** The repository name: the last segment of host/owner/name, else the fallback (the directory name). */
21
+ function repoName(result, fallback) {
22
+ const last = typeof result.repo === 'string' ? result.repo.split('/').pop() : '';
23
+ return (0, list_parse_1.cleanLine)(last || fallback, 100);
24
+ }
25
+ function renderJson(result) {
26
+ try {
27
+ if (!usable(result))
28
+ return '{"error":"invalid-input"}\n';
29
+ const changes = result.changes.map((c) => ({ sha: c.sha, subject: c.subject, classification: c.classification, merged_at: c.merged_at, pr: c.pr, release: c.release, records: c.records }));
30
+ return `${JSON.stringify({ schema: exports.LIST_SCHEMA, repo: result.repo, base: result.base, since: result.since, adopted: result.adopted, truncated: result.truncated, omitted: result.omitted, changes })}\n`;
31
+ }
32
+ catch {
33
+ return '{"error":"invalid-input"}\n';
34
+ }
35
+ }
36
+ const header = ['WHEN'.padEnd(16), 'CLASSIFICATION'.padEnd(14), 'SHA'.padEnd(8), 'PR'.padEnd(6), 'CHANGE'].join(' ');
37
+ function textRow(c) {
38
+ const first = c.records[0];
39
+ const title = first !== undefined && isValid(first) ? first.title : c.subject;
40
+ // The first FAILING record of an invalid-record commit (the valid ones carry an empty diagnostics list).
41
+ const failing = c.records.find((r) => Array.isArray(r.diagnostics) && r.diagnostics.length > 0);
42
+ const diagnostic = failing !== undefined && !isValid(failing) ? failing.diagnostics[0] : undefined;
43
+ const when = c.merged_at === null ? 'unknown' : String(c.merged_at).slice(0, 16).replace('T', ' ');
44
+ const cells = [
45
+ (0, list_parse_1.cleanLine)(when, 16).padEnd(16),
46
+ (0, list_parse_1.cleanLine)(c.classification, 14).padEnd(14),
47
+ (0, list_parse_1.cleanLine)(String(c.sha).slice(0, 8), 8).padEnd(8),
48
+ (c.pr === null ? '-' : (0, list_parse_1.cleanLine)(`#${c.pr}`, 12)).padEnd(6),
49
+ `${(0, list_parse_1.cleanLine)(title, MAX_TITLE)}${c.release ? ` [${(0, list_parse_1.cleanLine)(c.release, 255)}]` : ''}${diagnostic ? ` (${(0, list_parse_1.cleanLine)(diagnostic.code, 64)})` : ''}`,
50
+ ];
51
+ return (0, text_safety_1.clipMessage)(cells.join(' '));
52
+ }
53
+ function renderText(result, fallbackName) {
54
+ try {
55
+ if (!usable(result))
56
+ return INVALID_TEXT;
57
+ const name = repoName(result, fallbackName);
58
+ const base = (0, list_parse_1.cleanLine)(result.base, 255);
59
+ if (result.adopted === false)
60
+ return `${(0, text_safety_1.clipMessage)(`${name} has not adopted Change Records (no record under docs/changes/ on ${base}).`)}\n`;
61
+ if (result.changes.length === 0)
62
+ return `${(0, text_safety_1.clipMessage)(`No changes on ${base} since ${(0, list_parse_1.cleanLine)(result.since, 40)}.`)}\n`;
63
+ const lines = [(0, text_safety_1.clipMessage)(header), ...result.changes.map(textRow)];
64
+ if (result.truncated)
65
+ lines.push(`... ${count(result.omitted)} older change(s) not shown; narrow --since to see them.`);
66
+ return `${lines.join('\n')}\n`;
67
+ }
68
+ catch {
69
+ return INVALID_TEXT;
70
+ }
71
+ }
72
+ /** The summary as a sentence: it ends in one of . ! ? … (a period is added when it does not). */
73
+ function spoken(summary) {
74
+ const s = (0, list_parse_1.cleanLine)(summary, 280);
75
+ return s !== '' && '.!?…'.includes(s[s.length - 1]) ? s : `${s}.`;
76
+ }
77
+ function sentence(name, r) {
78
+ const pending = Array.isArray(r.manual_validation) ? r.manual_validation.length : 0;
79
+ const verified = (0, text_safety_1.isObj)(r.verification) && r.verification.overall === 'pass' ? 'verificado' : 'sin verificar';
80
+ return clipVoice(`${name}: ${spoken(r.summary_short)} ${(0, list_parse_1.cleanLine)(r.type, 64)}, ${verified}, ${pending} validaciones manuales pendientes.`);
81
+ }
82
+ function renderVoice(result, fallbackName) {
83
+ try {
84
+ if (!usable(result))
85
+ return INVALID_TEXT;
86
+ const name = repoName(result, fallbackName);
87
+ if (result.adopted === false)
88
+ return `${clipVoice(`${name}: todavía no usa Change Records.`)}\n`;
89
+ if (result.changes.length === 0)
90
+ return `${clipVoice(`${name}: sin cambios desde ${(0, list_parse_1.cleanLine)(result.since.slice(0, 10), 10)}.`)}\n`;
91
+ const lines = [];
92
+ for (const c of result.changes) {
93
+ if (c.classification !== 'recorded')
94
+ continue;
95
+ for (const r of c.records)
96
+ if (isValid(r))
97
+ lines.push(sentence(name, r));
98
+ }
99
+ const missing = result.changes.filter((c) => c.classification === 'missing').length;
100
+ const invalid = result.changes.filter((c) => c.classification === 'invalid-record').length;
101
+ lines.push(`Además: ${missing} cambio(s) sin record y ${invalid} con record inválido.`);
102
+ if (result.truncated)
103
+ lines.push(`Hay ${count(result.omitted)} cambio(s) más antiguo(s) sin mostrar; acota el período.`);
104
+ return `${lines.join('\n')}\n`;
105
+ }
106
+ catch {
107
+ return INVALID_TEXT;
108
+ }
109
+ }