agentic-workflow-manager 9.18.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.
- package/dist/src/commands/change/index.js +492 -0
- package/dist/src/commands/changes/index.js +84 -0
- package/dist/src/commands/plan/index.js +1 -1
- package/dist/src/commands/sensors/index.js +9 -0
- package/dist/src/commands/sensors/read-only-observation.js +305 -0
- package/dist/src/commands/sensors/run.js +24 -60
- package/dist/src/core/admission/index.js +11 -1
- package/dist/src/core/changes/command.js +38 -0
- package/dist/src/core/changes/derive.js +120 -0
- package/dist/src/core/changes/fs-safe.js +47 -0
- package/dist/src/core/changes/git-env.js +68 -0
- package/dist/src/core/changes/git.js +216 -0
- package/dist/src/core/changes/list-parse.js +67 -0
- package/dist/src/core/changes/list.js +408 -0
- package/dist/src/core/changes/parse.js +72 -0
- package/dist/src/core/changes/range.js +53 -0
- package/dist/src/core/changes/receipt.js +274 -0
- package/dist/src/core/changes/record-io.js +149 -0
- package/dist/src/core/changes/render-list.js +109 -0
- package/dist/src/core/changes/render.js +178 -0
- package/dist/src/core/changes/text-safety.js +116 -0
- package/dist/src/core/changes/trailers.js +76 -0
- package/dist/src/core/changes/types.js +26 -0
- package/dist/src/core/changes/validate-shape.js +219 -0
- package/dist/src/core/changes/worktree.js +53 -0
- package/dist/src/core/currentness/check.js +5 -2
- package/dist/src/index.js +4 -0
- package/dist/src/release/orchestrator.js +3 -1
- package/dist/tests/commands/change/new.test.js +1126 -0
- package/dist/tests/commands/change/validate.test.js +1145 -0
- package/dist/tests/commands/changes/list.test.js +423 -0
- package/dist/tests/commands/sensors/index.test.js +1 -1
- package/dist/tests/commands/sensors/read-only-observation.test.js +537 -0
- package/dist/tests/commands/sensors/run-read-only.test.js +151 -0
- package/dist/tests/core/admission/index.test.js +60 -0
- package/dist/tests/core/changes/derive.test.js +455 -0
- package/dist/tests/core/changes/fs-safe.test.js +82 -0
- package/dist/tests/core/changes/git-env.test.js +143 -0
- package/dist/tests/core/changes/git.test.js +442 -0
- package/dist/tests/core/changes/helpers.js +269 -0
- package/dist/tests/core/changes/list-fixtures.js +122 -0
- package/dist/tests/core/changes/list-long-history.test.js +52 -0
- package/dist/tests/core/changes/list-paging.test.js +511 -0
- package/dist/tests/core/changes/list-parse.test.js +120 -0
- package/dist/tests/core/changes/list-tree.test.js +44 -0
- package/dist/tests/core/changes/list.test.js +821 -0
- package/dist/tests/core/changes/parse.test.js +142 -0
- package/dist/tests/core/changes/predicates.test.js +83 -0
- package/dist/tests/core/changes/range.test.js +92 -0
- package/dist/tests/core/changes/receipt.test.js +947 -0
- package/dist/tests/core/changes/record-io.test.js +307 -0
- package/dist/tests/core/changes/record.test.js +549 -0
- package/dist/tests/core/changes/render-list.test.js +212 -0
- package/dist/tests/core/changes/render.test.js +202 -0
- package/dist/tests/core/changes/text-safety.test.js +226 -0
- package/dist/tests/core/changes/trailers.test.js +181 -0
- package/dist/tests/core/changes/worktree.test.js +259 -0
- package/dist/tests/core/currentness/check-insteadof.test.js +120 -0
- package/dist/tests/core/currentness/check.test.js +75 -14
- package/dist/tests/release/orchestrator-trailer.test.js +72 -0
- package/dist/tests/release/orchestrator.test.js +1 -0
- package/dist/tests/structural/change-commands-are-registered.test.js +29 -0
- package/dist/tests/structural/change-record-git-and-platform-conventions.test.js +77 -0
- package/dist/tests/structural/cloud-admission-docs-contract.test.js +54 -0
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/win32-arm64/secure_fs.node +0 -0
- package/prebuilds/win32-x64/secure_fs.node +0 -0
|
@@ -0,0 +1,408 @@
|
|
|
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.TAG_LOOKUP_LIMIT = exports.PAGE_SIZE = exports.MAX_SUBJECT = exports.MAX_RECORDS_PER_RUN = exports.MAX_RECORDS_PER_ENTRY = exports.DEFAULT_LIMIT = void 0;
|
|
7
|
+
exports.parseTreeEntries = parseTreeEntries;
|
|
8
|
+
exports.listChanges = listChanges;
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const git_1 = require("./git");
|
|
11
|
+
const parse_1 = require("./parse");
|
|
12
|
+
const range_1 = require("./range");
|
|
13
|
+
const text_safety_1 = require("./text-safety");
|
|
14
|
+
const list_parse_1 = require("./list-parse");
|
|
15
|
+
const trailers_1 = require("./trailers");
|
|
16
|
+
const types_1 = require("./types");
|
|
17
|
+
const validate_shape_1 = require("./validate-shape");
|
|
18
|
+
// `awm changes list` (R5): the first-parent history of the base ref, classified commit by commit. Everything here is read-only
|
|
19
|
+
// and reads git history only: never the working tree, never HEAD. Nothing throws: failures come back as a value. Everything git
|
|
20
|
+
// prints (commit ids, dates, subjects, paths, tag names, record contents) is untrusted: it is validated before it is used and
|
|
21
|
+
// escaped before it is returned. The base is pinned to one commit id, so every later call describes the same history.
|
|
22
|
+
exports.DEFAULT_LIMIT = 200;
|
|
23
|
+
exports.MAX_RECORDS_PER_ENTRY = 50;
|
|
24
|
+
exports.MAX_RECORDS_PER_RUN = 500;
|
|
25
|
+
exports.MAX_SUBJECT = 1024;
|
|
26
|
+
/** The first-parent history is read in pages of at most this many commits, so no git output approaches the 16 MiB bound. */
|
|
27
|
+
exports.PAGE_SIZE = 10_000;
|
|
28
|
+
const MAX_TAG_NAME = 255;
|
|
29
|
+
/** R5.5: how many containing tags (earliest-dated first) are examined for an entry's release. */
|
|
30
|
+
exports.TAG_LOOKUP_LIMIT = 500;
|
|
31
|
+
const MAX_IDENTITY = 300;
|
|
32
|
+
/** The largest instant a Date can hold, in seconds. */
|
|
33
|
+
const MAX_EPOCH_SECONDS = 8.64e12;
|
|
34
|
+
/**
|
|
35
|
+
* Columns of the subject that git is asked to print (`%<(N,trunc)`): enough for MAX_SUBJECT code units even when every character
|
|
36
|
+
* is two columns wide, and it bounds the output however long a subject is. Shorter subjects come back padded with spaces.
|
|
37
|
+
*/
|
|
38
|
+
const SUBJECT_COLUMNS = 2100;
|
|
39
|
+
const SUBJECT_UNAVAILABLE = '<subject unavailable>';
|
|
40
|
+
/** Columns of the END of the subject that git is asked to print: the trailing `(#<n>)` is found there, however long the subject is. */
|
|
41
|
+
const TAIL_COLUMNS = 64;
|
|
42
|
+
const fail = (code, message, remedy) => ({ ok: false, failure: remedy === undefined ? { code, message } : { code, message, remedy } });
|
|
43
|
+
// ---- Text ----
|
|
44
|
+
/** A value read from a valid record with bidi marks escaped in every string and key (the shape was validated, so it is shallow). */
|
|
45
|
+
function cleanValue(value, depth = 0) {
|
|
46
|
+
if (typeof value === 'string')
|
|
47
|
+
return (0, text_safety_1.escapeBidi)(value);
|
|
48
|
+
if (depth > 8)
|
|
49
|
+
return null;
|
|
50
|
+
if (Array.isArray(value))
|
|
51
|
+
return value.map((v) => cleanValue(v, depth + 1));
|
|
52
|
+
// fromEntries defines own properties: a key named __proto__ stays a key.
|
|
53
|
+
if ((0, text_safety_1.isObj)(value))
|
|
54
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [(0, text_safety_1.escapeBidi)(k), cleanValue(v, depth + 1)]));
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
const cleanDiagnostics = (list) => list.map((d) => ({ ...d, message: (0, text_safety_1.escapeBidi)(d.message), ...(d.field === undefined ? {} : { field: (0, text_safety_1.escapeBidi)(d.field) }) }));
|
|
58
|
+
const UNREADABLE = { ok: false };
|
|
59
|
+
const RECORD_PATHSPEC = `:(top,literal)${types_1.RECORD_DIR}`;
|
|
60
|
+
/** Files directly in docs/changes whose name looks like a record's date prefix (`*` does not cross `/` in a glob pathspec). Narrows R5.4's search; the exact R1.1 shape is checked per commit. */
|
|
61
|
+
const RECORD_SHAPED_PATHSPEC = `:(top,glob)${types_1.RECORD_DIR}/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-*.md`;
|
|
62
|
+
const SEP = '\x1f';
|
|
63
|
+
const GIT_FAILED_RECORD = { path: types_1.RECORD_DIR, diagnostics: [{ code: 'git-failed', message: 'git could not list the records added by this commit' }] };
|
|
64
|
+
/** Files added by a root commit under the record directory (the two-tree form of addedFilesUnder needs a parent). */
|
|
65
|
+
function addedByRoot(root, sha) {
|
|
66
|
+
const out = (0, git_1.runGit)(root, ['-c', 'diff.relative=false', 'diff-tree', '--root', '-r', '--no-renames', '--ignore-submodules=none', '--diff-filter=A', '--name-only', '-z', '--no-commit-id', '--end-of-options', sha, '--', RECORD_PATHSPEC]);
|
|
67
|
+
return out === null ? null : out.split('\0').filter(Boolean);
|
|
68
|
+
}
|
|
69
|
+
/** `text` without the spaces `%<(N,trunc)` pads a short subject with (a subject has no meaningful trailing blanks). */
|
|
70
|
+
function trimPadding(text) {
|
|
71
|
+
let end = text.length;
|
|
72
|
+
while (end > 0 && text[end - 1] === ' ')
|
|
73
|
+
end--;
|
|
74
|
+
return text.slice(0, end);
|
|
75
|
+
}
|
|
76
|
+
/** `<sha> SEP <field>` for the given commits in one call (`field` is a bounded git placeholder); null when git fails. */
|
|
77
|
+
function readFormatted(root, shas, field) {
|
|
78
|
+
const out = (0, git_1.runGit)(root, ['log', ...git_1.LOG_SAFETY_ARGS, '--no-walk=unsorted', '-z', `--format=%H%x1f${field}`, '--end-of-options', ...shas, '--']);
|
|
79
|
+
if (out === null)
|
|
80
|
+
return null;
|
|
81
|
+
const values = new Map();
|
|
82
|
+
for (const entry of out.split('\0')) {
|
|
83
|
+
if (entry === '')
|
|
84
|
+
continue;
|
|
85
|
+
const at = entry.indexOf(SEP);
|
|
86
|
+
const sha = at < 0 ? '' : entry.slice(0, at);
|
|
87
|
+
if (!types_1.COMMIT_ID.test(sha))
|
|
88
|
+
return null;
|
|
89
|
+
values.set(sha, trimPadding(entry.slice(at + 1)));
|
|
90
|
+
}
|
|
91
|
+
return values;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* A bounded placeholder of every commit, asked from git in one call. When the batch fails (a value that still outgrows git's
|
|
95
|
+
* output bound, for instance one made of zero-width characters, which the column bound does not count) it is halved until the
|
|
96
|
+
* commit at fault is alone; that one gets `fallback`: a subject never makes the run fail.
|
|
97
|
+
*/
|
|
98
|
+
function readBounded(root, shas, field, fallback) {
|
|
99
|
+
const values = new Map();
|
|
100
|
+
// A failing batch is halved until the commit that breaks it is alone: one bad commit among N costs about 2 log2(N) calls.
|
|
101
|
+
const fill = (part) => {
|
|
102
|
+
if (part.length === 0)
|
|
103
|
+
return;
|
|
104
|
+
const batch = readFormatted(root, part, field);
|
|
105
|
+
if (batch !== null && part.every((s) => batch.has(s))) {
|
|
106
|
+
for (const sha of part)
|
|
107
|
+
values.set(sha, batch.get(sha));
|
|
108
|
+
}
|
|
109
|
+
else if (part.length === 1) {
|
|
110
|
+
values.set(part[0], fallback);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
const middle = part.length >> 1;
|
|
114
|
+
fill(part.slice(0, middle));
|
|
115
|
+
fill(part.slice(middle));
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
fill(shas);
|
|
119
|
+
return values;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* R5.5: the earliest-created tag that contains `sha`, annotated tags first (their creation time is the tagger date); a lightweight
|
|
123
|
+
* tag (git records only the commit date for it) is chosen only when no annotated tag contains the commit. Ties go by name ascending.
|
|
124
|
+
* Bounded: only the TAG_LOOKUP_LIMIT earliest-dated containing tags are examined (a commit contained in thousands of tags would
|
|
125
|
+
* otherwise cost an output proportional to them for every entry); the rules above apply to that list, so an annotated tag dated
|
|
126
|
+
* after that many earlier ones is not seen. Documented limit of R5.5.
|
|
127
|
+
* `{ ok: false }` when git fails.
|
|
128
|
+
*/
|
|
129
|
+
function earliestTag(root, sha) {
|
|
130
|
+
// For-each-ref sorts by the LAST --sort first: creation date, then name.
|
|
131
|
+
const out = (0, git_1.runGit)(root, ['for-each-ref', '--contains', sha, '--sort=refname', '--sort=creatordate', `--count=${exports.TAG_LOOKUP_LIMIT}`, '--format=%(objecttype) %(refname)', 'refs/tags/']);
|
|
132
|
+
if (out === null)
|
|
133
|
+
return UNREADABLE;
|
|
134
|
+
const rows = out.split('\n').filter((row) => row !== '');
|
|
135
|
+
if (rows.length === 0)
|
|
136
|
+
return { ok: true, value: null };
|
|
137
|
+
const pick = rows.find((row) => row.startsWith('tag ')) ?? rows[0];
|
|
138
|
+
const ref = pick.slice(pick.indexOf(' ') + 1);
|
|
139
|
+
const name = ref.slice('refs/tags/'.length);
|
|
140
|
+
// A name longer than the cap is clipped, never an error.
|
|
141
|
+
return ref.startsWith('refs/tags/') && name !== '' ? { ok: true, value: (0, list_parse_1.cleanLine)(name, MAX_TAG_NAME) } : UNREADABLE;
|
|
142
|
+
}
|
|
143
|
+
/** Mode, type, object id and size of exactly the given paths in the commit's tree, in ONE call. Null when git fails. */
|
|
144
|
+
function readTreeEntries(root, sha, names) {
|
|
145
|
+
const out = (0, git_1.runGit)(root, ['ls-tree', '-l', '-z', '--full-tree', '--end-of-options', sha, '--', ...names]);
|
|
146
|
+
return out === null ? null : parseTreeEntries(out);
|
|
147
|
+
}
|
|
148
|
+
/** The pure part of readTreeEntries: parses the NUL-separated output of `git ls-tree -l -z`. Never throws. */
|
|
149
|
+
function parseTreeEntries(out) {
|
|
150
|
+
const entries = new Map();
|
|
151
|
+
if (typeof out !== 'string')
|
|
152
|
+
return entries;
|
|
153
|
+
for (const line of out.split('\0')) {
|
|
154
|
+
const tab = line.indexOf('\t');
|
|
155
|
+
if (tab < 0)
|
|
156
|
+
continue;
|
|
157
|
+
const [mode, type, oid, size] = line.slice(0, tab).split(' ').filter(Boolean);
|
|
158
|
+
if (mode === undefined || type === undefined || oid === undefined || size === undefined)
|
|
159
|
+
continue;
|
|
160
|
+
const name = line.slice(tab + 1);
|
|
161
|
+
// A blob whose size git cannot tell is an object that is not in the repository: in a partial clone, with lazy fetch off,
|
|
162
|
+
// git 2.55 prints `BAD` as its size (and fails the listing in older versions). It is unreadable, not "not a regular
|
|
163
|
+
// file": a negative size marks it so that the caller reports git-failed. (`-` is what trees and gitlinks show.)
|
|
164
|
+
if (type === 'blob' && (size === 'BAD' || size === '-') && types_1.COMMIT_ID.test(oid)) {
|
|
165
|
+
entries.set(name, { mode, type, oid, size: -1 });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (!types_1.COMMIT_ID.test(oid) || !/^\d{1,15}$/.test(size)) {
|
|
169
|
+
entries.set(name, { mode, type: 'unknown', oid, size: 0 });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
entries.set(name, { mode, type, oid, size: Number(size) });
|
|
173
|
+
}
|
|
174
|
+
return entries;
|
|
175
|
+
}
|
|
176
|
+
const invalid = (rel, code, message) => ({ path: rel, diagnostics: [{ code, message }] });
|
|
177
|
+
/** One record of a commit: size and kind are checked BEFORE the blob is read; the shape is validated without the working tree. */
|
|
178
|
+
function readOne(root, rel, info) {
|
|
179
|
+
if (info === undefined)
|
|
180
|
+
return invalid(rel, 'record-invalid', 'the record is not in the commit');
|
|
181
|
+
if (info.type !== 'blob' || (info.mode !== '100644' && info.mode !== '100755'))
|
|
182
|
+
return invalid(rel, 'record-invalid', 'the record is not a regular file');
|
|
183
|
+
if (info.size < 0)
|
|
184
|
+
return invalid(rel, 'git-failed', 'git could not read the record');
|
|
185
|
+
if (info.size > types_1.MAX_RECORD_BYTES)
|
|
186
|
+
return invalid(rel, 'record-too-large', `record exceeds ${types_1.MAX_RECORD_BYTES} bytes`);
|
|
187
|
+
const bytes = (0, git_1.runGitBytes)(root, ['show', '--no-textconv', '--end-of-options', info.oid]);
|
|
188
|
+
if (bytes === null)
|
|
189
|
+
return invalid(rel, 'git-failed', 'git could not read the record');
|
|
190
|
+
// Strict, as validate reads a record (record-io.ts): a lossy decode would turn bad bytes into U+FFFD and report the record valid.
|
|
191
|
+
// `ignoreBOM: true` keeps a leading BOM, so the record then fails as invalid in both.
|
|
192
|
+
let text;
|
|
193
|
+
try {
|
|
194
|
+
text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return invalid(rel, 'record-invalid', `${(0, text_safety_1.esc)(path_1.default.posix.basename(rel))} is not valid UTF-8`);
|
|
198
|
+
}
|
|
199
|
+
const shape = (0, validate_shape_1.validateRecordShape)(path_1.default.posix.basename(rel), text);
|
|
200
|
+
if (shape.record)
|
|
201
|
+
return { path: rel, ...cleanValue(shape.record) };
|
|
202
|
+
return { path: rel, diagnostics: cleanDiagnostics(shape.diagnostics) };
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The records a commit added. A commit with more records than the per-commit bound, or than what is left of the per-run budget, is
|
|
206
|
+
* not read at all: one `too-many-records` diagnostic stands for it. The budget is spent only on records actually read.
|
|
207
|
+
*/
|
|
208
|
+
function readRecords(root, sha, names, budget) {
|
|
209
|
+
if (names.length > budget.entry || names.length > budget.run) {
|
|
210
|
+
return [invalid(types_1.RECORD_DIR, 'too-many-records', `${names.length} records were added; more than the read limit allows, none was read`)];
|
|
211
|
+
}
|
|
212
|
+
budget.run -= names.length;
|
|
213
|
+
const tree = readTreeEntries(root, sha, names);
|
|
214
|
+
return names.map((rel) => (tree === null ? invalid(rel, 'git-failed', 'git could not read the record') : readOne(root, rel, tree.get(rel))));
|
|
215
|
+
}
|
|
216
|
+
const isInvalid = (r) => 'diagnostics' in r;
|
|
217
|
+
/** First-parent commits, newest first, with committer date and parents. Null when git prints something unexpected. */
|
|
218
|
+
function parseScan(out) {
|
|
219
|
+
const entries = [];
|
|
220
|
+
for (const row of out.split('\0')) {
|
|
221
|
+
if (row === '')
|
|
222
|
+
continue;
|
|
223
|
+
const [sha, seconds, parentText, ...rest] = row.split(SEP);
|
|
224
|
+
if (rest.length > 0 || sha === undefined || seconds === undefined || parentText === undefined || !types_1.COMMIT_ID.test(sha))
|
|
225
|
+
return null;
|
|
226
|
+
const parents = parentText.split(' ').filter(Boolean);
|
|
227
|
+
if (!parents.every((p) => types_1.COMMIT_ID.test(p)))
|
|
228
|
+
return null;
|
|
229
|
+
// An empty, negative, non-numeric or out-of-range committer date is a date that cannot be shown to be outside any window.
|
|
230
|
+
const valid = seconds.length > 0 && seconds.length <= 13 && /^\d+$/.test(seconds) && Number(seconds) <= MAX_EPOCH_SECONDS;
|
|
231
|
+
entries.push({ sha, ms: valid ? Number(seconds) * 1000 : null, parents });
|
|
232
|
+
}
|
|
233
|
+
return entries;
|
|
234
|
+
}
|
|
235
|
+
/** True/false whether `sha` added a record-shaped file (R1.1) under docs/changes; null when git cannot tell. */
|
|
236
|
+
function addsRecordShapedFile(root, sha, firstParent) {
|
|
237
|
+
const added = firstParent === undefined ? addedByRoot(root, sha) : (0, git_1.addedFilesUnder)(root, firstParent, sha, types_1.RECORD_DIR);
|
|
238
|
+
return added === null ? null : added.some(parse_1.isRecordPath);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* R5.4: the oldest first-parent commit that added a record-shaped file (R1.1) under `docs/changes`; a README or any other file
|
|
242
|
+
* there does not count. The history is read in pages of `pageSize` commits that touch a file with a record-like name (each page
|
|
243
|
+
* continues from the first parent of the previous page's last commit), then the candidates are checked from the oldest up: the
|
|
244
|
+
* first that really added a record-shaped file is the adoption. `{ ok: true, value: null }` when there is none, `{ ok: false }`
|
|
245
|
+
* when git fails.
|
|
246
|
+
*/
|
|
247
|
+
function findAdoption(root, pinned, pageSize) {
|
|
248
|
+
let start = pinned;
|
|
249
|
+
const candidates = [];
|
|
250
|
+
for (;;) {
|
|
251
|
+
const out = (0, git_1.runGit)(root, ['log', ...git_1.LOG_SAFETY_ARGS, '--first-parent', `--max-count=${pageSize}`, '-s', '-z', '--format=%H%x1f%P', '--end-of-options', start, '--', RECORD_SHAPED_PATHSPEC]);
|
|
252
|
+
if (out === null)
|
|
253
|
+
return UNREADABLE;
|
|
254
|
+
const rows = out.split('\0').filter(Boolean).map((row) => row.split(SEP));
|
|
255
|
+
if (!rows.every(([sha, parents, ...rest]) => rest.length === 0 && types_1.COMMIT_ID.test(sha) && parents !== undefined))
|
|
256
|
+
return UNREADABLE;
|
|
257
|
+
for (const [sha, parents] of rows)
|
|
258
|
+
candidates.push({ sha, firstParent: parents.split(' ').filter(Boolean)[0] });
|
|
259
|
+
const next = rows.length === 0 ? undefined : candidates[candidates.length - 1].firstParent;
|
|
260
|
+
if (rows.length < pageSize || next === undefined || !types_1.COMMIT_ID.test(next))
|
|
261
|
+
break;
|
|
262
|
+
start = next;
|
|
263
|
+
}
|
|
264
|
+
for (let i = candidates.length - 1; i >= 0; i--) {
|
|
265
|
+
const adds = addsRecordShapedFile(root, candidates[i].sha, candidates[i].firstParent);
|
|
266
|
+
if (adds === null)
|
|
267
|
+
return UNREADABLE;
|
|
268
|
+
if (adds)
|
|
269
|
+
return { ok: true, value: candidates[i].sha };
|
|
270
|
+
}
|
|
271
|
+
return { ok: true, value: null };
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* R5.7: the first-parent commits from `pinned` down to the adopting commit, in pages of `pageSize`, each starting at the first
|
|
275
|
+
* parent of the previous page's last commit. Every commit is tested individually against the window; the first `cap` that are
|
|
276
|
+
* in it are kept and all of them are counted. The scan stops at the adoption point (the walk ends there) or at the first page
|
|
277
|
+
* with no commit in the window: dates are assumed not to decrease along the chain, so a commit dated inside the window beyond
|
|
278
|
+
* such a page is not listed. Null when git fails or prints something unexpected.
|
|
279
|
+
*/
|
|
280
|
+
function scanWindow(root, pinned, adoption, sinceMs, pageSize, cap) {
|
|
281
|
+
const window = { selected: [], total: 0 };
|
|
282
|
+
let start = pinned;
|
|
283
|
+
for (;;) {
|
|
284
|
+
const out = (0, git_1.runGit)(root, ['log', ...git_1.LOG_SAFETY_ARGS, '--first-parent', `--max-count=${pageSize}`, '-z', '--format=%H%x1f%ct%x1f%P', '--end-of-options', start, `^${adoption}^@`, '--']);
|
|
285
|
+
const page = out === null ? null : parseScan(out);
|
|
286
|
+
if (page === null)
|
|
287
|
+
return null;
|
|
288
|
+
const inside = page.filter((e) => e.ms === null || e.ms >= sinceMs);
|
|
289
|
+
if (inside.length === 0)
|
|
290
|
+
return window;
|
|
291
|
+
for (const entry of inside)
|
|
292
|
+
if (window.selected.length < cap)
|
|
293
|
+
window.selected.push(entry);
|
|
294
|
+
window.total += inside.length;
|
|
295
|
+
const last = page[page.length - 1];
|
|
296
|
+
const next = last.parents[0];
|
|
297
|
+
if (page.length < pageSize || last.sha === adoption || next === undefined)
|
|
298
|
+
return window;
|
|
299
|
+
start = next;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function classifyEntry(root, entry, subject, tail, budget) {
|
|
303
|
+
const [first] = entry.parents;
|
|
304
|
+
const added = first === undefined ? addedByRoot(root, entry.sha) : (0, git_1.addedFilesUnder)(root, first, entry.sha, types_1.RECORD_DIR);
|
|
305
|
+
const names = added === null ? null : added.filter(parse_1.isRecordPath);
|
|
306
|
+
let records;
|
|
307
|
+
let classification;
|
|
308
|
+
if (names === null) {
|
|
309
|
+
records = [GIT_FAILED_RECORD];
|
|
310
|
+
classification = 'invalid-record';
|
|
311
|
+
}
|
|
312
|
+
else if (names.length > 0) {
|
|
313
|
+
records = readRecords(root, entry.sha, names, budget);
|
|
314
|
+
classification = records.some(isInvalid) ? 'invalid-record' : 'recorded';
|
|
315
|
+
// An invalid-record commit lists EVERY added record: the valid ones with no diagnostics.
|
|
316
|
+
if (classification === 'invalid-record')
|
|
317
|
+
records = records.map((r) => (isInvalid(r) ? r : { path: r.path, diagnostics: [] }));
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
records = [];
|
|
321
|
+
// The commits of a change: the commit itself, plus for a merge commit everything it brought in.
|
|
322
|
+
const revArgs = entry.parents.length > 1 ? [`${first}..${entry.sha}`] : ['-1', entry.sha];
|
|
323
|
+
classification = (0, trailers_1.hasOptOut)(root, revArgs) ? 'exempt' : 'missing';
|
|
324
|
+
}
|
|
325
|
+
const release = earliestTag(root, entry.sha);
|
|
326
|
+
if (!release.ok)
|
|
327
|
+
return null;
|
|
328
|
+
return {
|
|
329
|
+
sha: entry.sha,
|
|
330
|
+
subject: (0, list_parse_1.cleanLine)(subject, exports.MAX_SUBJECT),
|
|
331
|
+
classification,
|
|
332
|
+
merged_at: entry.ms === null ? null : new Date(entry.ms).toISOString(),
|
|
333
|
+
pr: (0, list_parse_1.parsePr)(tail),
|
|
334
|
+
release: release.value,
|
|
335
|
+
records,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
const optionalCount = (v) => v === undefined || (typeof v === 'number' && Number.isSafeInteger(v) && v >= 1);
|
|
339
|
+
/** The repository identity, or null when the remote is absent or unusable (a host that still carries a user part is not trusted). */
|
|
340
|
+
function safeRepo(root) {
|
|
341
|
+
const identity = (0, git_1.repoIdentity)(root);
|
|
342
|
+
if (identity === null || identity.split('/')[0].includes('@'))
|
|
343
|
+
return null;
|
|
344
|
+
return (0, list_parse_1.cleanLine)(identity, MAX_IDENTITY);
|
|
345
|
+
}
|
|
346
|
+
function listUnsafe(root, opts) {
|
|
347
|
+
if (typeof root !== 'string' || root.length === 0 || root.includes('\0'))
|
|
348
|
+
return fail('invalid-input', 'the repository root must be a non-empty string');
|
|
349
|
+
if (!(0, text_safety_1.isObj)(opts))
|
|
350
|
+
return fail('invalid-input', 'the options must be an object');
|
|
351
|
+
const { base: explicit, since, limit, maxRecordsPerEntry, maxRecords, pageSize } = opts;
|
|
352
|
+
if (!(since instanceof Date) || Number.isNaN(since.getTime()))
|
|
353
|
+
return fail('invalid-input', 'the window start must be a valid date');
|
|
354
|
+
if (!optionalCount(limit) || !optionalCount(maxRecordsPerEntry) || !optionalCount(maxRecords) || !optionalCount(pageSize))
|
|
355
|
+
return fail('invalid-input', 'limits must be positive integers');
|
|
356
|
+
// R3.1/R3.2/R3.5, as `change new`; then the base is pinned to one commit id: history cannot move under the run.
|
|
357
|
+
const resolved = (0, range_1.resolveBaseRef)(root, explicit);
|
|
358
|
+
if (!resolved.ok)
|
|
359
|
+
return { ok: false, failure: resolved.failure };
|
|
360
|
+
const { base, sha: pinned } = resolved;
|
|
361
|
+
// R5.1: at a shallow boundary a commit looks like a root commit, so a shallow history cannot be classified.
|
|
362
|
+
const shallow = (0, git_1.runGit)(root, ['rev-parse', '--is-shallow-repository'])?.trim();
|
|
363
|
+
if (shallow === 'true')
|
|
364
|
+
return fail('shallow-history', 'this repository is a shallow clone; its oldest commits cannot be told from root commits', 'git fetch --unshallow');
|
|
365
|
+
if (shallow !== 'false')
|
|
366
|
+
return fail('git-failed', 'git could not tell whether the repository is shallow');
|
|
367
|
+
const page = Math.min(pageSize ?? exports.PAGE_SIZE, exports.PAGE_SIZE);
|
|
368
|
+
// R5.4: the first commit, in first-parent order, that touched docs/changes.
|
|
369
|
+
const adopting = findAdoption(root, pinned, page);
|
|
370
|
+
if (!adopting.ok)
|
|
371
|
+
return fail('git-failed', 'git could not read the history');
|
|
372
|
+
const adoption = adopting.value;
|
|
373
|
+
const result = { repo: safeRepo(root), base: (0, list_parse_1.cleanLine)(base, MAX_TAG_NAME), since: since.toISOString(), adopted: false, truncated: false, omitted: 0, changes: [] };
|
|
374
|
+
if (adoption === null)
|
|
375
|
+
return { ok: true, result };
|
|
376
|
+
result.adopted = true;
|
|
377
|
+
const cap = Math.min(limit ?? exports.DEFAULT_LIMIT, exports.DEFAULT_LIMIT);
|
|
378
|
+
const window = scanWindow(root, pinned, adoption, since.getTime(), page, cap);
|
|
379
|
+
if (window === null)
|
|
380
|
+
return fail('git-failed', 'git could not read the history');
|
|
381
|
+
const selected = window.selected;
|
|
382
|
+
result.truncated = window.total > selected.length;
|
|
383
|
+
result.omitted = window.total - selected.length;
|
|
384
|
+
const shas = selected.map((e) => e.sha);
|
|
385
|
+
const subjects = readBounded(root, shas, `%<(${SUBJECT_COLUMNS},trunc)%s`, SUBJECT_UNAVAILABLE);
|
|
386
|
+
const tails = readBounded(root, shas, `%<(${TAIL_COLUMNS},ltrunc)%s`, '');
|
|
387
|
+
const run = maxRecords ?? exports.MAX_RECORDS_PER_RUN;
|
|
388
|
+
const budget = { entry: maxRecordsPerEntry ?? exports.MAX_RECORDS_PER_ENTRY, run };
|
|
389
|
+
for (const entry of selected) {
|
|
390
|
+
const change = classifyEntry(root, entry, subjects.get(entry.sha), tails.get(entry.sha), budget);
|
|
391
|
+
if (change === null)
|
|
392
|
+
return fail('git-failed', `git could not tell which tags contain ${entry.sha.slice(0, 12)}`);
|
|
393
|
+
result.changes.push(change);
|
|
394
|
+
}
|
|
395
|
+
return { ok: true, result };
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* R5.1-R5.5, R5.7: the first-parent history of the base ref, classified. `base` is resolved as in R3.1 (an unsafe one is
|
|
399
|
+
* `invalid-input`, one that does not resolve is `base-unresolved`). Read-only; never throws.
|
|
400
|
+
*/
|
|
401
|
+
function listChanges(root, opts) {
|
|
402
|
+
try {
|
|
403
|
+
return listUnsafe(root, opts);
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
return fail('internal-error', `unexpected failure (${(0, text_safety_1.esc)(error instanceof Error ? error.name : 'unknown')})`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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.isRecordPath = exports.RECORD_FILE_RE = void 0;
|
|
7
|
+
exports.isValidSlug = isValidSlug;
|
|
8
|
+
exports.isValidRecordFileName = isValidRecordFileName;
|
|
9
|
+
exports.splitRecord = splitRecord;
|
|
10
|
+
exports.loadFrontmatter = loadFrontmatter;
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const js_yaml_1 = __importDefault(require("js-yaml"));
|
|
13
|
+
const text_safety_1 = require("./text-safety");
|
|
14
|
+
const types_1 = require("./types");
|
|
15
|
+
exports.RECORD_FILE_RE = /^(\d{4})-(\d{2})-(\d{2})-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$/;
|
|
16
|
+
/** True for a repo-relative, `/`-separated path directly inside `docs/changes/` whose file name looks like a record (`YYYY-MM-DD-<slug>.md`). */
|
|
17
|
+
const isRecordPath = (rel) => typeof rel === 'string' && path_1.default.posix.dirname(rel) === types_1.RECORD_DIR && exports.RECORD_FILE_RE.test(path_1.default.posix.basename(rel));
|
|
18
|
+
exports.isRecordPath = isRecordPath;
|
|
19
|
+
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
20
|
+
function isValidSlug(slug) {
|
|
21
|
+
return typeof slug === 'string' && slug.length > 0 && slug.length <= 64 && SLUG_RE.test(slug);
|
|
22
|
+
}
|
|
23
|
+
function isValidRecordFileName(name) {
|
|
24
|
+
if (typeof name !== 'string')
|
|
25
|
+
return false;
|
|
26
|
+
const m = exports.RECORD_FILE_RE.exec(name);
|
|
27
|
+
if (!m || !isValidSlug(m[4]))
|
|
28
|
+
return false;
|
|
29
|
+
const iso = `${m[1]}-${m[2]}-${m[3]}`;
|
|
30
|
+
const d = new Date(`${iso}T00:00:00Z`);
|
|
31
|
+
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === iso;
|
|
32
|
+
}
|
|
33
|
+
const INPUT_INVALID = { code: 'input-invalid', message: 'input must be a string' };
|
|
34
|
+
/** Normalizes CRLF and splits a leading `---` frontmatter block from the body. */
|
|
35
|
+
function splitRecord(raw) {
|
|
36
|
+
if (typeof raw !== 'string')
|
|
37
|
+
return { ok: false, diagnostic: INPUT_INVALID };
|
|
38
|
+
const text = raw.replace(/\r\n/g, '\n');
|
|
39
|
+
if (!text.startsWith('---\n'))
|
|
40
|
+
return { ok: false, diagnostic: { code: 'frontmatter-missing', message: 'record must start with a --- frontmatter block' } };
|
|
41
|
+
const end = text.indexOf('\n---\n', 3);
|
|
42
|
+
if (end < 0)
|
|
43
|
+
return { ok: false, diagnostic: { code: 'frontmatter-missing', message: 'frontmatter block is not closed with ---' } };
|
|
44
|
+
return { ok: true, frontmatterText: text.slice(4, end), body: text.slice(end + 5) };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* CORE_SCHEMA: no custom tags, no implicit timestamps (dates stay strings).
|
|
48
|
+
* Every anchor is rejected (scalar, collection, used or unused); an alias cannot exist without one.
|
|
49
|
+
* The parse listener sees the anchor of each node on `close`, so quoted `&a` / `*a` text is unaffected.
|
|
50
|
+
*/
|
|
51
|
+
function loadFrontmatter(text) {
|
|
52
|
+
if (typeof text !== 'string')
|
|
53
|
+
return { ok: false, diagnostic: INPUT_INVALID };
|
|
54
|
+
let value;
|
|
55
|
+
let anchored = false;
|
|
56
|
+
try {
|
|
57
|
+
value = js_yaml_1.default.load(text, {
|
|
58
|
+
schema: js_yaml_1.default.CORE_SCHEMA,
|
|
59
|
+
json: false,
|
|
60
|
+
listener: (event, state) => {
|
|
61
|
+
if (event === 'close' && state.anchor !== null)
|
|
62
|
+
anchored = true;
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
return { ok: false, diagnostic: { code: 'yaml-invalid', message: (0, text_safety_1.clipMessage)(`frontmatter is not valid YAML: ${(0, text_safety_1.escapeText)(error.message.split('\n')[0])}`) } };
|
|
68
|
+
}
|
|
69
|
+
if (anchored)
|
|
70
|
+
return { ok: false, diagnostic: { code: 'yaml-alias', message: 'YAML anchors/aliases are not allowed' } };
|
|
71
|
+
return { ok: true, value };
|
|
72
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HEAD_MOVED = void 0;
|
|
4
|
+
exports.resolveBaseRef = resolveBaseRef;
|
|
5
|
+
exports.resolveRange = resolveRange;
|
|
6
|
+
exports.headMovedFailure = headMovedFailure;
|
|
7
|
+
exports.recordDirProblem = recordDirProblem;
|
|
8
|
+
const git_1 = require("./git");
|
|
9
|
+
const text_safety_1 = require("./text-safety");
|
|
10
|
+
const types_1 = require("./types");
|
|
11
|
+
const REMEDY_BASE = 'pass --base <ref>';
|
|
12
|
+
exports.HEAD_MOVED = { code: 'head-moved', message: 'HEAD moved during derivation' };
|
|
13
|
+
const KINDS = ['ok', 'absent', 'unsafe', 'error'];
|
|
14
|
+
const failure = (code, message, remedy) => ({ ok: false, failure: remedy === undefined ? { code, message } : { code, message, remedy } });
|
|
15
|
+
/**
|
|
16
|
+
* R3.1/R3.2/R3.5: the base ref: `explicitBase` (checked with `isSafeRef` before git sees it: `invalid-input`), else origin/HEAD,
|
|
17
|
+
* main, master; `base-unresolved` when none resolves. The one place that decides it, for `change new`, `change validate` and `changes list`.
|
|
18
|
+
*/
|
|
19
|
+
function resolveBaseRef(root, explicitBase) {
|
|
20
|
+
if (explicitBase !== undefined && !(0, git_1.isSafeRef)(explicitBase))
|
|
21
|
+
return failure('invalid-input', `--base "${(0, text_safety_1.esc)(explicitBase)}" is not a safe git ref`);
|
|
22
|
+
const resolved = (0, git_1.resolveBaseCommit)(root, explicitBase);
|
|
23
|
+
return resolved === null ? failure('base-unresolved', 'could not resolve a base branch', REMEDY_BASE) : { ok: true, ...resolved };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* R3.1/R3.2/R3.5: pins HEAD, then resolves the base (explicit `--base`, else origin/HEAD, main, master) and its merge base with HEAD.
|
|
27
|
+
* An explicit base is checked with `isSafeRef` before git sees it.
|
|
28
|
+
*/
|
|
29
|
+
function resolveRange(root, explicitBase) {
|
|
30
|
+
const head = (0, git_1.headSha)(root);
|
|
31
|
+
if (head === null)
|
|
32
|
+
return failure('git-failed', 'git could not read HEAD');
|
|
33
|
+
const resolved = resolveBaseRef(root, explicitBase);
|
|
34
|
+
if (!resolved.ok)
|
|
35
|
+
return resolved;
|
|
36
|
+
const mergeBaseSha = (0, git_1.mergeBase)(root, resolved.base);
|
|
37
|
+
if (mergeBaseSha === null)
|
|
38
|
+
return failure('base-unresolved', `no merge base between "${(0, text_safety_1.esc)(resolved.base)}" and HEAD`, REMEDY_BASE);
|
|
39
|
+
return { ok: true, head, mergeBase: mergeBaseSha };
|
|
40
|
+
}
|
|
41
|
+
/** `HEAD_MOVED` when HEAD is no longer `head` (or cannot be read), else null: a verdict about a moving target is never given. */
|
|
42
|
+
function headMovedFailure(root, head) {
|
|
43
|
+
return (0, git_1.headSha)(root) === head ? null : exports.HEAD_MOVED;
|
|
44
|
+
}
|
|
45
|
+
function recordDirProblem(dir, purpose) {
|
|
46
|
+
if (!(0, text_safety_1.isObj)(dir) || !KINDS.includes(dir.kind))
|
|
47
|
+
return { code: 'invalid-input', message: 'the record directory state is not valid' };
|
|
48
|
+
if (dir.kind === 'unsafe')
|
|
49
|
+
return { code: 'unsafe-path', message: `${types_1.RECORD_DIR} or its parent is a symlink or not a directory; refusing to ${purpose === 'read' ? 'read' : 'write'}` };
|
|
50
|
+
if (dir.kind === 'error' || (dir.kind === 'absent' && purpose === 'create'))
|
|
51
|
+
return { code: 'io-failed', message: `could not ${purpose === 'create' ? 'create' : 'inspect'} ${types_1.RECORD_DIR}` };
|
|
52
|
+
return null;
|
|
53
|
+
}
|