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.
- package/dist/src/commands/change/index.js +492 -0
- package/dist/src/commands/changes/index.js +84 -0
- package/dist/src/commands/sensors/index.js +9 -0
- package/dist/src/commands/sensors/run.js +1 -0
- 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/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/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/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/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,178 @@
|
|
|
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.renderSkeleton = renderSkeleton;
|
|
7
|
+
exports.refreshDerived = refreshDerived;
|
|
8
|
+
const util_1 = require("util");
|
|
9
|
+
const js_yaml_1 = __importDefault(require("js-yaml"));
|
|
10
|
+
const parse_1 = require("./parse");
|
|
11
|
+
const text_safety_1 = require("./text-safety");
|
|
12
|
+
const types_1 = require("./types");
|
|
13
|
+
// CORE_SCHEMA on both sides: the validator parses with it, so what is dumped here is exactly what it will read back.
|
|
14
|
+
const DUMP = { lineWidth: -1, noRefs: true, sortKeys: false, schema: js_yaml_1.default.CORE_SCHEMA };
|
|
15
|
+
const REPLACED = [...types_1.DERIVED_KEYS, 'verification'];
|
|
16
|
+
const isBareKeyChar = (c) => /^[\w.-]$/.test(c);
|
|
17
|
+
const bare = (line) => (line.endsWith('\r') ? line.slice(0, -1) : line);
|
|
18
|
+
/**
|
|
19
|
+
* The record skeleton: derived fields and verification are real, every narrative field and body section holds the pending marker.
|
|
20
|
+
* Returns null when the input cannot be dumped, or when the dump does not read back as the same data. Never throws.
|
|
21
|
+
*/
|
|
22
|
+
function renderSkeleton(input) {
|
|
23
|
+
try {
|
|
24
|
+
if (!(0, text_safety_1.isObj)(input) || typeof input.id !== 'string' || !(0, text_safety_1.isObj)(input.fields) || !(0, text_safety_1.isObj)(input.verification))
|
|
25
|
+
return null;
|
|
26
|
+
const front = {
|
|
27
|
+
schema: types_1.RECORD_SCHEMA,
|
|
28
|
+
id: input.id,
|
|
29
|
+
title: types_1.PENDING_MARKER,
|
|
30
|
+
summary_short: types_1.PENDING_MARKER,
|
|
31
|
+
...Object.fromEntries(types_1.DERIVED_KEYS.map((k) => [k, input.fields[k]])),
|
|
32
|
+
};
|
|
33
|
+
if ((0, text_safety_1.isObj)(input.links) && Object.keys(input.links).length > 0)
|
|
34
|
+
front.links = input.links;
|
|
35
|
+
front.verification = input.verification;
|
|
36
|
+
front.manual_validation = [{ step: types_1.PENDING_MARKER, expected: types_1.PENDING_MARKER }];
|
|
37
|
+
front.risks = [types_1.PENDING_MARKER];
|
|
38
|
+
front.rollback = types_1.PENDING_MARKER;
|
|
39
|
+
if ((0, text_safety_1.isObj)(input.provenance) && Object.keys(input.provenance).length > 0)
|
|
40
|
+
front.provenance = input.provenance;
|
|
41
|
+
const dumped = js_yaml_1.default.dump(front, DUMP);
|
|
42
|
+
const back = (0, parse_1.loadFrontmatter)(dumped.replace(/\n$/, ''));
|
|
43
|
+
if (!back.ok || !(0, util_1.isDeepStrictEqual)(back.value, front))
|
|
44
|
+
return null;
|
|
45
|
+
const body = types_1.BODY_HEADINGS.map((h) => `${h}\n\n${types_1.PENDING_MARKER}\n`).join('\n');
|
|
46
|
+
return `---\n${dumped}---\n\n${body}`;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The key of a top-level `key: ...` line, or null when the line does not start a mapping entry the scan understands. Accepted
|
|
54
|
+
* spellings: bare (`type:`), double- or single-quoted on one line, each with optional spaces or tabs before the colon. A quoted
|
|
55
|
+
* key that needs unescaping (`\"`, `\u0074`, `''`) returns its raw text, which never equals a derived key name: such an
|
|
56
|
+
* entry is kept as it is, and a record that only spells a derived key that way fails the "every key seen" check instead.
|
|
57
|
+
* A single left-to-right index scan: linear in the line, no regular expression over file text.
|
|
58
|
+
*/
|
|
59
|
+
function topKey(line) {
|
|
60
|
+
let i = 0;
|
|
61
|
+
let key;
|
|
62
|
+
const first = line.charAt(0);
|
|
63
|
+
if (first === '"' || first === "'") {
|
|
64
|
+
for (i = 1;; i++) {
|
|
65
|
+
if (i >= line.length)
|
|
66
|
+
return null;
|
|
67
|
+
const c = line[i];
|
|
68
|
+
if (first === '"' && c === '\\') {
|
|
69
|
+
i++;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (c === first) {
|
|
73
|
+
if (first === "'" && line[i + 1] === "'") {
|
|
74
|
+
i++;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
key = line.slice(1, i);
|
|
81
|
+
i++;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
if (!/^[A-Za-z_]$/.test(first))
|
|
85
|
+
return null;
|
|
86
|
+
for (i = 1; i < line.length && isBareKeyChar(line[i]); i++)
|
|
87
|
+
;
|
|
88
|
+
key = line.slice(0, i);
|
|
89
|
+
}
|
|
90
|
+
while (line[i] === ' ' || line[i] === '\t')
|
|
91
|
+
i++;
|
|
92
|
+
if (line[i] !== ':')
|
|
93
|
+
return null;
|
|
94
|
+
const after = line[i + 1];
|
|
95
|
+
if (after !== undefined && after !== ' ' && after !== '\t' && after !== '\r')
|
|
96
|
+
return null;
|
|
97
|
+
return key;
|
|
98
|
+
}
|
|
99
|
+
/** Blank lines and comments after a block's last content line belong to what follows, not to the value. */
|
|
100
|
+
const isFiller = (line) => {
|
|
101
|
+
const t = bare(line).trim();
|
|
102
|
+
return t === '' || t.startsWith('#');
|
|
103
|
+
};
|
|
104
|
+
function splice(raw, fields, verification) {
|
|
105
|
+
if (typeof raw !== 'string' || !(0, text_safety_1.isObj)(fields) || !(0, text_safety_1.isObj)(verification))
|
|
106
|
+
return null;
|
|
107
|
+
const split = (0, parse_1.splitRecord)(raw);
|
|
108
|
+
if (!split.ok)
|
|
109
|
+
return null;
|
|
110
|
+
const loaded = (0, parse_1.loadFrontmatter)(split.frontmatterText);
|
|
111
|
+
if (!loaded.ok || !(0, text_safety_1.isObj)(loaded.value))
|
|
112
|
+
return null;
|
|
113
|
+
const before = loaded.value;
|
|
114
|
+
const fresh = { ...Object.fromEntries(types_1.DERIVED_KEYS.map((k) => [k, fields[k]])), verification };
|
|
115
|
+
// Work on the raw lines, not on the normalized text: each line keeps its own terminator (CRLF, LF or mixed).
|
|
116
|
+
const lines = raw.split('\n');
|
|
117
|
+
const close = lines.findIndex((l, i) => i > 0 && bare(l) === '---');
|
|
118
|
+
if (bare(lines[0]) !== '---' || close < 1 || close >= lines.length - 1)
|
|
119
|
+
return null;
|
|
120
|
+
const starts = [];
|
|
121
|
+
const keys = [];
|
|
122
|
+
for (let i = 1; i < close; i++) {
|
|
123
|
+
const key = topKey(lines[i]);
|
|
124
|
+
if (key !== null) {
|
|
125
|
+
starts.push(i);
|
|
126
|
+
keys.push(key);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (starts.length === 0)
|
|
130
|
+
return null;
|
|
131
|
+
const seen = new Set();
|
|
132
|
+
const out = lines.slice(0, starts[0]);
|
|
133
|
+
starts.forEach((start, n) => {
|
|
134
|
+
const end = starts[n + 1] ?? close;
|
|
135
|
+
const key = keys[n];
|
|
136
|
+
if (!REPLACED.includes(key)) {
|
|
137
|
+
out.push(...lines.slice(start, end));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
seen.add(key);
|
|
141
|
+
let last = end - 1;
|
|
142
|
+
while (last > start && isFiller(lines[last]))
|
|
143
|
+
last--;
|
|
144
|
+
const cr = lines[start].endsWith('\r') ? '\r' : '';
|
|
145
|
+
const dumped = js_yaml_1.default.dump({ [key]: fresh[key] }, DUMP).replace(/\n$/, '').split('\n').map((l) => l + cr);
|
|
146
|
+
out.push(...dumped, ...lines.slice(last + 1, end));
|
|
147
|
+
});
|
|
148
|
+
out.push(...lines.slice(close));
|
|
149
|
+
if (!REPLACED.every((k) => seen.has(k)))
|
|
150
|
+
return null;
|
|
151
|
+
const next = out.join('\n');
|
|
152
|
+
// Fail closed: the result must parse and hold, for every key of the original, the fresh value (replaced keys) or the old one
|
|
153
|
+
// (all the others). Anything the line scan misjudged (odd YAML layouts) ends here instead of in a corrupted record.
|
|
154
|
+
const reread = (0, parse_1.splitRecord)(next);
|
|
155
|
+
if (!reread.ok)
|
|
156
|
+
return null;
|
|
157
|
+
const after = (0, parse_1.loadFrontmatter)(reread.frontmatterText);
|
|
158
|
+
if (!after.ok || !(0, text_safety_1.isObj)(after.value))
|
|
159
|
+
return null;
|
|
160
|
+
for (const key of Object.keys(before)) {
|
|
161
|
+
if (!(0, util_1.isDeepStrictEqual)(after.value[key], REPLACED.includes(key) ? fresh[key] : before[key]))
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
return next;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* R3.11: replaces only the top-level blocks of the derived keys and `verification`; every other byte (comments, blank lines,
|
|
168
|
+
* other keys, the body, each line's own line ending) is preserved. Null when the record is unparseable, a replaced key is
|
|
169
|
+
* missing, the input is unusable, or the result would not read back as intended. Never throws.
|
|
170
|
+
*/
|
|
171
|
+
function refreshDerived(raw, fields, verification) {
|
|
172
|
+
try {
|
|
173
|
+
return splice(raw, fields, verification);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The one place that defines which characters are unsafe in record text (R1.4) and how attacker-controlled text is
|
|
3
|
+
// echoed in diagnostics (R1.8). Pure: imports nothing from this module's siblings.
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.cleanText = exports.isWellFormed = exports.isObj = exports.escapeBidi = exports.isSafeMultiline = exports.isSingleLine = exports.MAX_MESSAGE = exports.NON_STRING = void 0;
|
|
6
|
+
exports.escapeText = escapeText;
|
|
7
|
+
exports.sliceNoSplit = sliceNoSplit;
|
|
8
|
+
exports.esc = esc;
|
|
9
|
+
exports.isSafeRelativePath = isSafeRelativePath;
|
|
10
|
+
exports.isCanonicalIso = isCanonicalIso;
|
|
11
|
+
exports.isHttpsUrl = isHttpsUrl;
|
|
12
|
+
exports.clipMessage = clipMessage;
|
|
13
|
+
// Control characters are C0 (includes \t \n \r), DEL and C1 (includes U+0085). U+2028/U+2029 are line breaks but not controls.
|
|
14
|
+
const CONTROL_CLASS = '\\u0000-\\u001f\\u007f-\\u009f';
|
|
15
|
+
const SINGLE_LINE_UNSAFE = new RegExp(`[${CONTROL_CLASS}\\u2028\\u2029]`);
|
|
16
|
+
// What an echo of untrusted text escapes: the above plus the bidirectional controls (marks, embeddings, isolates), which would
|
|
17
|
+
// reorder what a terminal shows around them. They are escaped, not rejected: isSingleLine and friends are about controls only.
|
|
18
|
+
const BIDI = '\\u061c\\u200e\\u200f\\u202a-\\u202e\\u2066-\\u2069';
|
|
19
|
+
const ESCAPE_ALL = new RegExp(`[${CONTROL_CLASS}\\u2028\\u2029${BIDI}]`, 'g');
|
|
20
|
+
const BIDI_ALL = new RegExp(`[${BIDI}]`, 'g');
|
|
21
|
+
const MULTILINE_UNSAFE = new RegExp(`[${CONTROL_CLASS}]`);
|
|
22
|
+
/** Stand-in returned by the escaping and clipping helpers for non-string input (they never throw). */
|
|
23
|
+
exports.NON_STRING = '<non-string>';
|
|
24
|
+
exports.MAX_MESSAGE = 300;
|
|
25
|
+
const MAX_ECHO_CODE_POINTS = 80;
|
|
26
|
+
const MAX_ECHO_ESCAPED = 240;
|
|
27
|
+
/** A string with no line break and no control character. */
|
|
28
|
+
const isSingleLine = (text) => typeof text === 'string' && !SINGLE_LINE_UNSAFE.test(text);
|
|
29
|
+
exports.isSingleLine = isSingleLine;
|
|
30
|
+
/** A string whose only control characters are `\n` and `\t`. */
|
|
31
|
+
const isSafeMultiline = (text) => typeof text === 'string' && !MULTILINE_UNSAFE.test(text.replace(/[\n\t]/g, ''));
|
|
32
|
+
exports.isSafeMultiline = isSafeMultiline;
|
|
33
|
+
const unicodeEscape = (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`;
|
|
34
|
+
/** Bidirectional controls written as \\uXXXX (nothing else is touched). */
|
|
35
|
+
const escapeBidi = (text) => text.replace(BIDI_ALL, unicodeEscape);
|
|
36
|
+
exports.escapeBidi = escapeBidi;
|
|
37
|
+
/** JSON-string escaping (without the quotes) plus the line breaks and controls JSON leaves alone. Does not clip. */
|
|
38
|
+
function escapeText(text) {
|
|
39
|
+
if (typeof text !== 'string')
|
|
40
|
+
return exports.NON_STRING;
|
|
41
|
+
return JSON.stringify(text).slice(1, -1).replace(ESCAPE_ALL, unicodeEscape);
|
|
42
|
+
}
|
|
43
|
+
/** Cuts at `maxUnits` UTF-16 units, dropping a trailing high surrogate so a pair is never split. The only cut of untrusted text by unit count. */
|
|
44
|
+
function sliceNoSplit(text, maxUnits) {
|
|
45
|
+
// Non-string text, or a limit that is not a finite number of at least 1, yields '' (never throws); fractions are floored.
|
|
46
|
+
if (typeof text !== 'string' || typeof maxUnits !== 'number' || !Number.isFinite(maxUnits))
|
|
47
|
+
return '';
|
|
48
|
+
const limit = Math.floor(maxUnits);
|
|
49
|
+
if (limit < 1)
|
|
50
|
+
return '';
|
|
51
|
+
if (text.length <= limit)
|
|
52
|
+
return text;
|
|
53
|
+
const last = text.charCodeAt(limit - 1);
|
|
54
|
+
return text.slice(0, last >= 0xd800 && last <= 0xdbff ? limit - 1 : limit);
|
|
55
|
+
}
|
|
56
|
+
/** Attacker-controlled text made safe to embed in a diagnostic: bounded, JSON-escaped, never containing a raw line break or control. */
|
|
57
|
+
function esc(text) {
|
|
58
|
+
if (typeof text !== 'string')
|
|
59
|
+
return exports.NON_STRING;
|
|
60
|
+
const points = [...text];
|
|
61
|
+
const clipped = points.length > MAX_ECHO_CODE_POINTS ? `${points.slice(0, MAX_ECHO_CODE_POINTS).join('')}…` : text;
|
|
62
|
+
const escaped = escapeText(clipped);
|
|
63
|
+
return escaped.length > MAX_ECHO_ESCAPED ? `${sliceNoSplit(escaped, MAX_ECHO_ESCAPED)}…` : escaped;
|
|
64
|
+
}
|
|
65
|
+
const MAX_RELATIVE_PATH = 1024;
|
|
66
|
+
/**
|
|
67
|
+
* A repo-relative path a record, a pathspec or a module name can safely hold: single line (no control character, no
|
|
68
|
+
* U+2028/U+2029), 1..1024 UTF-16 units, not padded with whitespace, no backslash, not absolute (a leading `/` is an empty
|
|
69
|
+
* segment, `C:` a drive letter; backslashes are refused), and no empty, `.` or `..` segment. Linear time; never throws.
|
|
70
|
+
*/
|
|
71
|
+
function isSafeRelativePath(p) {
|
|
72
|
+
if (!(0, exports.isSingleLine)(p) || p.length === 0 || p.length > MAX_RELATIVE_PATH || p !== p.trim())
|
|
73
|
+
return false;
|
|
74
|
+
if (/^[A-Za-z]:/.test(p) || p.includes('\\'))
|
|
75
|
+
return false;
|
|
76
|
+
return p.split('/').every((s) => s !== '' && s !== '.' && s !== '..');
|
|
77
|
+
}
|
|
78
|
+
/** A plain object (not null, not an array). */
|
|
79
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
80
|
+
exports.isObj = isObj;
|
|
81
|
+
/** Round-trips through UTF-8: false for a lone surrogate, which would not survive being written to a file. */
|
|
82
|
+
const isWellFormed = (s) => typeof s === 'string' && Buffer.from(s, 'utf8').toString('utf8') === s;
|
|
83
|
+
exports.isWellFormed = isWellFormed;
|
|
84
|
+
/** A canonical ISO-8601 instant: exactly what `Date.prototype.toISOString` prints (UTC, milliseconds, `Z`). Never throws. */
|
|
85
|
+
function isCanonicalIso(v) {
|
|
86
|
+
if (typeof v !== 'string')
|
|
87
|
+
return false;
|
|
88
|
+
const time = Date.parse(v);
|
|
89
|
+
return !Number.isNaN(time) && new Date(time).toISOString() === v;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* An https URL a record can hold: one line, no whitespace, `https://` followed by a real authority (the first character is not
|
|
93
|
+
* `/ ? # \ @`), parseable by WHATWG URL with a host. Linear time; never throws.
|
|
94
|
+
*/
|
|
95
|
+
function isHttpsUrl(v, opts) {
|
|
96
|
+
if (!(0, exports.isSingleLine)(v) || /\s/.test(v) || !/^https:\/\/[^/?#\\@]/.test(v))
|
|
97
|
+
return false;
|
|
98
|
+
if (opts.maxLength !== undefined && v.length > opts.maxLength)
|
|
99
|
+
return false;
|
|
100
|
+
try {
|
|
101
|
+
const u = new URL(v);
|
|
102
|
+
return u.hostname.length > 0 && (opts.allowCredentials || (u.username === '' && u.password === ''));
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Escapes any remaining raw line break, control character or bidi control (no quoting, no clipping). */
|
|
109
|
+
const cleanText = (text) => (typeof text === 'string' ? text.replace(ESCAPE_ALL, unicodeEscape) : exports.NON_STRING);
|
|
110
|
+
exports.cleanText = cleanText;
|
|
111
|
+
/** Clips to MAX_MESSAGE UTF-16 units including the ellipsis. */
|
|
112
|
+
function clipMessage(message) {
|
|
113
|
+
if (typeof message !== 'string')
|
|
114
|
+
return exports.NON_STRING;
|
|
115
|
+
return message.length <= exports.MAX_MESSAGE ? message : `${sliceNoSplit(message, exports.MAX_MESSAGE - 1)}…`;
|
|
116
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.optsOut = optsOut;
|
|
4
|
+
exports.hasOptOut = hasOptOut;
|
|
5
|
+
const git_1 = require("./git");
|
|
6
|
+
const OPT_OUT_TRAILER = 'Change-Record';
|
|
7
|
+
// Raw messages (%B), NUL-terminated by -z: a message cannot contain NUL, so one commit is exactly one record. Git's own
|
|
8
|
+
// %(trailers) is not used: it honours trailer.separators and trailer.<token>.key from the user's config, so what counts as
|
|
9
|
+
// an opt-out would differ per machine. Messages are untrusted and unbounded, so everything below is an index scan: no regex
|
|
10
|
+
// that can backtrack over a long run of blanks.
|
|
11
|
+
const FORMAT = '--format=%B';
|
|
12
|
+
const KEY = `${OPT_OUT_TRAILER}:`.toLowerCase();
|
|
13
|
+
const VALUE = 'none';
|
|
14
|
+
const COUNT_RE = /^-\d{1,4}$/;
|
|
15
|
+
const MAX_ARGS = 16;
|
|
16
|
+
const isBlank = (c) => c === ' ' || c === '\t';
|
|
17
|
+
/** True when every character of `line` is a space, tab or CR (a blank line separating paragraphs). */
|
|
18
|
+
function isBlankLine(line) {
|
|
19
|
+
for (let i = 0; i < line.length; i++)
|
|
20
|
+
if (!isBlank(line[i]) && line[i] !== '\r')
|
|
21
|
+
return false;
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
/** `Change-Record:` + optional spaces/tabs + `none` + optional spaces/tabs (+ the CR of a CRLF), key and value case-insensitive. */
|
|
25
|
+
function isOptOutLine(line) {
|
|
26
|
+
let end = line.length;
|
|
27
|
+
if (end > 0 && line[end - 1] === '\r')
|
|
28
|
+
end--;
|
|
29
|
+
if (end < KEY.length + VALUE.length || line.slice(0, KEY.length).toLowerCase() !== KEY)
|
|
30
|
+
return false;
|
|
31
|
+
let i = KEY.length;
|
|
32
|
+
while (i < end && isBlank(line[i]))
|
|
33
|
+
i++;
|
|
34
|
+
if (line.slice(i, i + VALUE.length).toLowerCase() !== VALUE)
|
|
35
|
+
return false;
|
|
36
|
+
i += VALUE.length;
|
|
37
|
+
while (i < end && isBlank(line[i]))
|
|
38
|
+
i++;
|
|
39
|
+
return i === end;
|
|
40
|
+
}
|
|
41
|
+
/** DD-6: a line of the final paragraph (never the subject's) that is exactly `Change-Record: none`. No folding, no other separator. */
|
|
42
|
+
function optsOut(message) {
|
|
43
|
+
const lines = message.split('\n');
|
|
44
|
+
let paragraphs = 0;
|
|
45
|
+
let start = 0;
|
|
46
|
+
let inside = false;
|
|
47
|
+
for (let i = 0; i < lines.length; i++) {
|
|
48
|
+
if (isBlankLine(lines[i]))
|
|
49
|
+
inside = false;
|
|
50
|
+
else if (!inside) {
|
|
51
|
+
inside = true;
|
|
52
|
+
start = i;
|
|
53
|
+
paragraphs++;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (paragraphs < 2)
|
|
57
|
+
return false;
|
|
58
|
+
for (let i = start; i < lines.length; i++)
|
|
59
|
+
if (isOptOutLine(lines[i]))
|
|
60
|
+
return true;
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* True when any commit selected by `revArgs` (e.g. ['a..b'] or ['-1', sha]) carries `Change-Record: none`.
|
|
65
|
+
* Only a commit count (`-N`) and safe revisions are accepted; anything else, and any git failure, is `false`.
|
|
66
|
+
*/
|
|
67
|
+
function hasOptOut(root, revArgs) {
|
|
68
|
+
if (!Array.isArray(revArgs) || revArgs.length === 0 || revArgs.length > MAX_ARGS)
|
|
69
|
+
return false;
|
|
70
|
+
const counts = revArgs.filter((a) => typeof a === 'string' && COUNT_RE.test(a));
|
|
71
|
+
const revs = revArgs.filter((a) => !counts.includes(a));
|
|
72
|
+
if (counts.length > 1 || revs.length === 0 || !revs.every(git_1.isSafeRevision))
|
|
73
|
+
return false;
|
|
74
|
+
const out = (0, git_1.runGit)(root, ['log', ...git_1.LOG_SAFETY_ARGS, '-z', FORMAT, ...counts, '--end-of-options', ...revs, '--']);
|
|
75
|
+
return out !== null && out.split('\0').some(optsOut);
|
|
76
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isSensorName = exports.isSensorStatus = exports.isOverall = exports.isCommitId = exports.COMMIT_ID = exports.MAX_SENSOR_NAME = exports.OVERALLS = exports.SENSOR_STATUSES = exports.DERIVED_KEYS = exports.BODY_HEADINGS = exports.MAX_DIAGNOSTICS = exports.MAX_RECORD_BYTES = exports.RECORD_DIR = exports.PENDING_MARKER = exports.RECORD_SCHEMA = void 0;
|
|
4
|
+
const text_safety_1 = require("./text-safety");
|
|
5
|
+
exports.RECORD_SCHEMA = 'awm.change-record/v1';
|
|
6
|
+
exports.PENDING_MARKER = '<!-- awm:pending -->';
|
|
7
|
+
exports.RECORD_DIR = 'docs/changes';
|
|
8
|
+
exports.MAX_RECORD_BYTES = 64 * 1024;
|
|
9
|
+
exports.MAX_DIAGNOSTICS = 50;
|
|
10
|
+
exports.BODY_HEADINGS = ['## Qué se hizo', '## Qué se probó', '## Qué falta validar', '## Riesgos y rollback'];
|
|
11
|
+
exports.DERIVED_KEYS = ['type', 'scope', 'semver', 'breaking', 'modules'];
|
|
12
|
+
// The ONE definition of what a receipt (receipt.ts), a record's verification block (validate-shape.ts) and git (git.ts) may hold.
|
|
13
|
+
exports.SENSOR_STATUSES = ['pass', 'fail', 'inconclusive', 'skipped'];
|
|
14
|
+
exports.OVERALLS = ['pass', 'fail', 'skipped', 'not_certified'];
|
|
15
|
+
exports.MAX_SENSOR_NAME = 128;
|
|
16
|
+
/** A full commit id: SHA-1 (40) or SHA-256 (64) lowercase hex characters. */
|
|
17
|
+
exports.COMMIT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
18
|
+
const isCommitId = (v) => typeof v === 'string' && exports.COMMIT_ID.test(v);
|
|
19
|
+
exports.isCommitId = isCommitId;
|
|
20
|
+
const isOverall = (v) => typeof v === 'string' && exports.OVERALLS.includes(v);
|
|
21
|
+
exports.isOverall = isOverall;
|
|
22
|
+
const isSensorStatus = (v) => typeof v === 'string' && exports.SENSOR_STATUSES.includes(v);
|
|
23
|
+
exports.isSensorStatus = isSensorStatus;
|
|
24
|
+
/** A sensor name: one line, 1 to 128 code points. */
|
|
25
|
+
const isSensorName = (v) => (0, text_safety_1.isSingleLine)(v) && [...v].length >= 1 && [...v].length <= exports.MAX_SENSOR_NAME;
|
|
26
|
+
exports.isSensorName = isSensorName;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.validateRecordShape = validateRecordShape;
|
|
4
|
+
const parse_1 = require("./parse");
|
|
5
|
+
const fs_safe_1 = require("./fs-safe");
|
|
6
|
+
const text_safety_1 = require("./text-safety");
|
|
7
|
+
const types_1 = require("./types");
|
|
8
|
+
const TOP_KEYS = ['schema', 'id', 'title', 'summary_short', 'type', 'scope', 'semver', 'breaking', 'modules', 'links', 'verification', 'manual_validation', 'risks', 'rollback', 'provenance'];
|
|
9
|
+
const REQUIRED_KEYS = TOP_KEYS.filter((k) => k !== 'links' && k !== 'provenance');
|
|
10
|
+
const PROVENANCE_KEYS = ['agent', 'model', 'session', 'started_at', 'finished_at'];
|
|
11
|
+
const SEMVER = ['major', 'minor', 'patch', 'none'];
|
|
12
|
+
const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
|
|
13
|
+
const nonEmpty = (v) => typeof v === 'string' && v.trim().length > 0;
|
|
14
|
+
/** Last line of defense for R1.8: single-line message/field, bounded length, at most MAX_DIAGNOSTICS entries. */
|
|
15
|
+
function finalize(list) {
|
|
16
|
+
const clean = list.map((d) => {
|
|
17
|
+
const message = (0, text_safety_1.cleanText)(d.message);
|
|
18
|
+
const base = { code: d.code, message: (0, text_safety_1.clipMessage)(message) };
|
|
19
|
+
return d.field === undefined ? base : { ...base, field: (0, text_safety_1.cleanText)(d.field) };
|
|
20
|
+
});
|
|
21
|
+
if (clean.length <= types_1.MAX_DIAGNOSTICS)
|
|
22
|
+
return { diagnostics: clean };
|
|
23
|
+
const kept = clean.slice(0, types_1.MAX_DIAGNOSTICS - 1);
|
|
24
|
+
const omitted = clean.length - kept.length;
|
|
25
|
+
return { diagnostics: [...kept, { code: 'diagnostics-truncated', message: `${omitted} more diagnostics omitted` }], omitted };
|
|
26
|
+
}
|
|
27
|
+
// ---- Value predicates ----
|
|
28
|
+
function isModulePath(m) {
|
|
29
|
+
return m === '.' || (0, text_safety_1.isSafeRelativePath)(m);
|
|
30
|
+
}
|
|
31
|
+
function containsPendingString(value) {
|
|
32
|
+
if (typeof value === 'string')
|
|
33
|
+
return value.includes(types_1.PENDING_MARKER);
|
|
34
|
+
if (Array.isArray(value))
|
|
35
|
+
return value.some(containsPendingString);
|
|
36
|
+
if ((0, text_safety_1.isObj)(value))
|
|
37
|
+
return Object.entries(value).some(([k, val]) => k.includes(types_1.PENDING_MARKER) || containsPendingString(val));
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
// ---- Diagnostic helpers ----
|
|
41
|
+
function unknownKeys(obj, allowed, prefix, out) {
|
|
42
|
+
for (const key of Object.keys(obj)) {
|
|
43
|
+
if (!allowed.includes(key)) {
|
|
44
|
+
const field = prefix + (0, text_safety_1.esc)(key);
|
|
45
|
+
out.push({ code: 'unknown-field', field, message: `unknown field "${field}"` });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function bad(out, field, message) {
|
|
50
|
+
out.push({ code: 'field-invalid', field, message });
|
|
51
|
+
}
|
|
52
|
+
// ---- Body ----
|
|
53
|
+
function checkBody(body, out) {
|
|
54
|
+
const lines = body.split('\n');
|
|
55
|
+
const headingIdx = lines.map((l, i) => (l.startsWith('## ') ? i : -1)).filter((i) => i >= 0);
|
|
56
|
+
let cursor = -1;
|
|
57
|
+
for (const heading of types_1.BODY_HEADINGS) {
|
|
58
|
+
const at = headingIdx.find((i) => i > cursor && lines[i].trimEnd() === heading);
|
|
59
|
+
if (at === undefined) {
|
|
60
|
+
out.push({ code: 'body-section-missing', message: `missing or out-of-order section "${heading}"` });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const next = headingIdx.find((i) => i > at) ?? lines.length;
|
|
64
|
+
if (lines.slice(at + 1, next).join('\n').trim().length === 0) {
|
|
65
|
+
out.push({ code: 'body-section-missing', message: `section "${heading}" is empty` });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
cursor = at;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// ---- Frontmatter sections ----
|
|
72
|
+
function checkIdentity(fm, fileName, out) {
|
|
73
|
+
if (fm.schema !== types_1.RECORD_SCHEMA)
|
|
74
|
+
bad(out, 'schema', `schema must be ${types_1.RECORD_SCHEMA}`);
|
|
75
|
+
if (fm.id !== fileName.replace(/\.md$/, ''))
|
|
76
|
+
bad(out, 'id', 'id must equal the file name without .md');
|
|
77
|
+
if (!nonEmpty(fm.title) || !(0, text_safety_1.isSingleLine)(fm.title) || [...fm.title].length > 120)
|
|
78
|
+
bad(out, 'title', 'title must be 1-120 characters on one line');
|
|
79
|
+
if (!nonEmpty(fm.summary_short) || !(0, text_safety_1.isSingleLine)(fm.summary_short) || [...fm.summary_short].length > 280)
|
|
80
|
+
bad(out, 'summary_short', 'summary_short must be 1-280 characters on one line');
|
|
81
|
+
if (typeof fm.type !== 'string' || !/^[a-z]+$/.test(fm.type))
|
|
82
|
+
bad(out, 'type', 'type must be a lowercase conventional-commit type');
|
|
83
|
+
if (!(fm.scope === null || (nonEmpty(fm.scope) && (0, text_safety_1.isSingleLine)(fm.scope))))
|
|
84
|
+
bad(out, 'scope', 'scope must be a single-line string or null');
|
|
85
|
+
if (!SEMVER.includes(fm.semver))
|
|
86
|
+
bad(out, 'semver', `semver must be one of ${SEMVER.join('|')}`);
|
|
87
|
+
if (typeof fm.breaking !== 'boolean')
|
|
88
|
+
bad(out, 'breaking', 'breaking must be a boolean');
|
|
89
|
+
}
|
|
90
|
+
function checkModules(fm, out) {
|
|
91
|
+
const mods = fm.modules;
|
|
92
|
+
if (!Array.isArray(mods) || !mods.every(isModulePath) || JSON.stringify([...new Set(mods)].sort()) !== JSON.stringify(mods)) {
|
|
93
|
+
bad(out, 'modules', 'modules must be a sorted, unique array of repo-relative directories');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function checkLinks(fm, root, out) {
|
|
97
|
+
if (fm.links === undefined)
|
|
98
|
+
return;
|
|
99
|
+
if (!(0, text_safety_1.isObj)(fm.links)) {
|
|
100
|
+
bad(out, 'links', 'links must be a mapping');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
unknownKeys(fm.links, ['plan', 'issue'], 'links.', out);
|
|
104
|
+
const { plan, issue } = fm.links;
|
|
105
|
+
if (plan !== undefined) {
|
|
106
|
+
if (!(0, text_safety_1.isSafeRelativePath)(plan))
|
|
107
|
+
bad(out, 'links.plan', 'links.plan must be a safe single-line repo-relative path');
|
|
108
|
+
else if (root !== undefined && !(0, fs_safe_1.isRegularFileInside)(root, plan))
|
|
109
|
+
bad(out, 'links.plan', `links.plan "${(0, text_safety_1.esc)(plan)}" does not exist as a regular file`);
|
|
110
|
+
}
|
|
111
|
+
if (issue !== undefined && !(0, text_safety_1.isHttpsUrl)(issue, { allowCredentials: false }))
|
|
112
|
+
bad(out, 'links.issue', 'links.issue must be an https URL without credentials');
|
|
113
|
+
}
|
|
114
|
+
function checkVerification(fm, out) {
|
|
115
|
+
const v = fm.verification;
|
|
116
|
+
if (!(0, text_safety_1.isObj)(v)) {
|
|
117
|
+
bad(out, 'verification', 'verification must be a mapping');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
unknownKeys(v, ['commit', 'overall', 'sensors', 'receipt_digest'], 'verification.', out);
|
|
121
|
+
if (!(0, types_1.isCommitId)(v.commit))
|
|
122
|
+
bad(out, 'verification.commit', 'verification.commit must be 40 or 64 lowercase hex characters');
|
|
123
|
+
if (!types_1.OVERALLS.includes(v.overall))
|
|
124
|
+
bad(out, 'verification.overall', `overall must be one of ${types_1.OVERALLS.join('|')}`);
|
|
125
|
+
if (!(0, text_safety_1.isObj)(v.sensors) || !Object.entries(v.sensors).every(([k, s]) => (0, types_1.isSensorName)(k) && (0, types_1.isSensorStatus)(s))) {
|
|
126
|
+
bad(out, 'verification.sensors', 'sensors must map sensor names to pass|fail|inconclusive|skipped');
|
|
127
|
+
}
|
|
128
|
+
if (typeof v.receipt_digest !== 'string' || !DIGEST_RE.test(v.receipt_digest))
|
|
129
|
+
bad(out, 'verification.receipt_digest', 'receipt_digest must be sha256:<64 hex>');
|
|
130
|
+
}
|
|
131
|
+
const isNarrative = (v) => nonEmpty(v) && (0, text_safety_1.isSafeMultiline)(v);
|
|
132
|
+
function checkNarrative(fm, out) {
|
|
133
|
+
const mv = fm.manual_validation;
|
|
134
|
+
if (Array.isArray(mv)) {
|
|
135
|
+
if (mv.length === 0)
|
|
136
|
+
bad(out, 'manual_validation', 'manual_validation must not be empty; use {none: <reason>}');
|
|
137
|
+
mv.forEach((item, i) => {
|
|
138
|
+
if (!(0, text_safety_1.isObj)(item)) {
|
|
139
|
+
bad(out, `manual_validation.${i}`, 'item must be a mapping');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
unknownKeys(item, ['step', 'expected'], `manual_validation.${i}.`, out);
|
|
143
|
+
if (!isNarrative(item.step) || !isNarrative(item.expected))
|
|
144
|
+
bad(out, `manual_validation.${i}`, 'step and expected must be non-empty strings without control characters');
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
else if ((0, text_safety_1.isObj)(mv)) {
|
|
148
|
+
unknownKeys(mv, ['none'], 'manual_validation.', out);
|
|
149
|
+
if (!isNarrative(mv.none))
|
|
150
|
+
bad(out, 'manual_validation.none', 'none must state a non-empty reason without control characters');
|
|
151
|
+
}
|
|
152
|
+
else
|
|
153
|
+
bad(out, 'manual_validation', 'manual_validation must be a list or {none: <reason>}');
|
|
154
|
+
if (!Array.isArray(fm.risks) || !fm.risks.every(isNarrative))
|
|
155
|
+
bad(out, 'risks', 'risks must be an array of non-empty strings without control characters');
|
|
156
|
+
if (!isNarrative(fm.rollback))
|
|
157
|
+
bad(out, 'rollback', 'rollback must be a non-empty string without control characters');
|
|
158
|
+
}
|
|
159
|
+
function checkProvenance(fm, out) {
|
|
160
|
+
if (fm.provenance === undefined)
|
|
161
|
+
return;
|
|
162
|
+
if (!(0, text_safety_1.isObj)(fm.provenance)) {
|
|
163
|
+
bad(out, 'provenance', 'provenance must be a mapping');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
unknownKeys(fm.provenance, PROVENANCE_KEYS, 'provenance.', out);
|
|
167
|
+
for (const [k, val] of Object.entries(fm.provenance)) {
|
|
168
|
+
if (!PROVENANCE_KEYS.includes(k))
|
|
169
|
+
continue;
|
|
170
|
+
const field = `provenance.${(0, text_safety_1.esc)(k)}`;
|
|
171
|
+
if (!nonEmpty(val) || !(0, text_safety_1.isSingleLine)(val))
|
|
172
|
+
bad(out, field, `${field} must be a non-empty single-line string`);
|
|
173
|
+
else if ((k === 'started_at' || k === 'finished_at') && !(0, text_safety_1.isCanonicalIso)(val))
|
|
174
|
+
bad(out, field, `${field} must be canonical ISO-8601`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** `omitted` is the number of diagnostics the MAX_DIAGNOSTICS cap dropped (absent when none were); the last diagnostic is then `diagnostics-truncated`. */
|
|
178
|
+
function validateRecordShape(fileName, raw, opts = {}) {
|
|
179
|
+
if (typeof fileName !== 'string' || typeof raw !== 'string') {
|
|
180
|
+
return { diagnostics: [{ code: 'input-invalid', message: 'fileName and raw must both be strings' }] };
|
|
181
|
+
}
|
|
182
|
+
if (Buffer.byteLength(raw, 'utf8') > types_1.MAX_RECORD_BYTES) {
|
|
183
|
+
return { diagnostics: [{ code: 'record-too-large', message: `record exceeds ${types_1.MAX_RECORD_BYTES} bytes` }] };
|
|
184
|
+
}
|
|
185
|
+
const root = typeof opts?.root === 'string' ? opts.root : undefined;
|
|
186
|
+
const out = [];
|
|
187
|
+
if (!(0, parse_1.isValidRecordFileName)(fileName))
|
|
188
|
+
out.push({ code: 'name-invalid', message: `file name "${(0, text_safety_1.esc)(fileName)}" must be YYYY-MM-DD-<slug>.md with a valid date and slug of at most 64 chars` });
|
|
189
|
+
const stop = (...more) => finalize([...out, ...more]);
|
|
190
|
+
const split = (0, parse_1.splitRecord)(raw);
|
|
191
|
+
if (!split.ok)
|
|
192
|
+
return stop(split.diagnostic);
|
|
193
|
+
if (split.frontmatterText.includes(types_1.PENDING_MARKER) || split.body.includes(types_1.PENDING_MARKER)) {
|
|
194
|
+
out.push({ code: 'pending-content', message: 'record still contains <!-- awm:pending --> placeholders' });
|
|
195
|
+
}
|
|
196
|
+
checkBody(split.body, out);
|
|
197
|
+
const loaded = (0, parse_1.loadFrontmatter)(split.frontmatterText);
|
|
198
|
+
if (!loaded.ok)
|
|
199
|
+
return stop(loaded.diagnostic);
|
|
200
|
+
const fm = loaded.value;
|
|
201
|
+
if (!(0, text_safety_1.isObj)(fm))
|
|
202
|
+
return stop({ code: 'not-an-object', message: 'frontmatter must be a mapping' });
|
|
203
|
+
if (!out.some((d) => d.code === 'pending-content') && containsPendingString(fm)) {
|
|
204
|
+
out.push({ code: 'pending-content', message: 'record still contains <!-- awm:pending --> placeholders' });
|
|
205
|
+
}
|
|
206
|
+
unknownKeys(fm, TOP_KEYS, '', out);
|
|
207
|
+
for (const key of REQUIRED_KEYS)
|
|
208
|
+
if (!(key in fm))
|
|
209
|
+
bad(out, key, `missing required field "${key}"`);
|
|
210
|
+
checkIdentity(fm, fileName, out);
|
|
211
|
+
checkModules(fm, out);
|
|
212
|
+
checkLinks(fm, root, out);
|
|
213
|
+
checkVerification(fm, out);
|
|
214
|
+
checkNarrative(fm, out);
|
|
215
|
+
checkProvenance(fm, out);
|
|
216
|
+
if (out.length === 0)
|
|
217
|
+
return { record: fm, diagnostics: [] };
|
|
218
|
+
return finalize(out);
|
|
219
|
+
}
|