agentic-workflow-manager 9.20.0 → 9.20.2
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 +2 -2
- package/dist/src/commands/changes/index.js +2 -1
- package/dist/src/core/changes/derive.js +21 -8
- package/dist/src/core/changes/git.js +37 -6
- package/dist/src/core/changes/range.js +28 -13
- package/dist/src/core/process/model.js +153 -10
- package/dist/tests/commands/change/new.test.js +28 -1
- package/dist/tests/commands/change/validate.test.js +3 -3
- package/dist/tests/commands/changes/list.test.js +1 -0
- package/dist/tests/commands/process.test.js +4 -0
- package/dist/tests/core/changes/derive.test.js +75 -2
- package/dist/tests/core/changes/git.test.js +83 -1
- package/dist/tests/core/changes/helpers.js +2 -2
- package/dist/tests/core/changes/list.test.js +6 -5
- package/dist/tests/core/changes/range.test.js +89 -0
- package/dist/tests/core/dashboard/write-html.test.js +2 -3
- package/dist/tests/core/evidence/store.test.js +2 -3
- package/dist/tests/core/ledger/store.test.js +3 -9
- package/dist/tests/core/process/discover.test.js +21 -0
- package/dist/tests/core/process/model.test.js +338 -0
- package/dist/tests/helpers/platform.js +27 -0
- package/dist/tests/structural/change-record-git-and-platform-conventions.test.js +6 -5
- package/dist/tests/structural/native-release-artifacts.test.js +295 -46
- package/dist/tests/structural/process-model-description-contract.test.js +34 -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
|
@@ -475,7 +475,7 @@ function registerChangeCommand(program, deps = {}) {
|
|
|
475
475
|
change
|
|
476
476
|
.command('new <slug>')
|
|
477
477
|
.description('create docs/changes/YYYY-MM-DD-<slug>.md with derived fields and pending narrative')
|
|
478
|
-
.option('--base <ref>',
|
|
478
|
+
.option('--base <ref>', range_1.BASE_OPTION_HELP)
|
|
479
479
|
.option('--plan <path>', 'repo-relative plan file to link')
|
|
480
480
|
.option('--issue <url>', 'https issue URL to link')
|
|
481
481
|
.option('--model <model>', 'provenance: model identifier')
|
|
@@ -485,7 +485,7 @@ function registerChangeCommand(program, deps = {}) {
|
|
|
485
485
|
change
|
|
486
486
|
.command('validate [path]')
|
|
487
487
|
.description('validate the Change Record(s) added by this branch, or one record (--strict also checks the sensor receipt)')
|
|
488
|
-
.option('--base <ref>',
|
|
488
|
+
.option('--base <ref>', range_1.BASE_OPTION_HELP)
|
|
489
489
|
.option('--strict', 'require a matching, passing sensor receipt for verification.commit')
|
|
490
490
|
.option('--json', 'print a single JSON line')
|
|
491
491
|
.action((target, opts) => inCwd((cwd) => runChangeValidate(cwd, target, opts), wantsJson(opts)));
|
|
@@ -10,6 +10,7 @@ const command_1 = require("../../core/changes/command");
|
|
|
10
10
|
const git_1 = require("../../core/changes/git");
|
|
11
11
|
const list_1 = require("../../core/changes/list");
|
|
12
12
|
const list_parse_1 = require("../../core/changes/list-parse");
|
|
13
|
+
const range_1 = require("../../core/changes/range");
|
|
13
14
|
const render_list_1 = require("../../core/changes/render-list");
|
|
14
15
|
const text_safety_1 = require("../../core/changes/text-safety");
|
|
15
16
|
const FORMATS = ['text', 'json', 'voice'];
|
|
@@ -78,7 +79,7 @@ function registerChangesCommand(program, deps = {}) {
|
|
|
78
79
|
.command('list')
|
|
79
80
|
.description('list the changes of the base branch with their Change Record and derived merge/release state')
|
|
80
81
|
.option('--since <window>', '<n>h, <n>d or YYYY-MM-DD (default 24h)')
|
|
81
|
-
.option('--base <ref>',
|
|
82
|
+
.option('--base <ref>', range_1.BASE_OPTION_HELP)
|
|
82
83
|
.option('--format <format>', 'text | json | voice (default text)')
|
|
83
84
|
.action((opts) => (0, command_1.inCwd)((cwd) => runChangesList(cwd, opts, deps), () => (0, command_1.fail)('not-a-git-repo', 'the working directory is not accessible')));
|
|
84
85
|
}
|
|
@@ -15,6 +15,7 @@ const types_1 = require("./types");
|
|
|
15
15
|
// Pathspecs relative to the repository top, so the result does not depend on the directory git runs in.
|
|
16
16
|
const ALL_FILES = ':/';
|
|
17
17
|
const EXCLUDE_RECORDS = `:(top,exclude,literal)${types_1.RECORD_DIR}`;
|
|
18
|
+
const EXCLUDE_PLANS = ':(top,exclude,literal)docs/plans';
|
|
18
19
|
const PARSER_TYPE = /^\w+$/;
|
|
19
20
|
const RECORD_TYPE = /^[a-z]+$/;
|
|
20
21
|
const MODULE_DEPTH = 3;
|
|
@@ -39,18 +40,22 @@ function usable(commits) {
|
|
|
39
40
|
}
|
|
40
41
|
const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
41
42
|
/**
|
|
42
|
-
* R3.3. `breaking` and `semver` cover every usable commit, with the release parser's exact-type semantics
|
|
43
|
-
*
|
|
43
|
+
* R3.3 as amended 2026-10-19. `breaking` and `semver` cover every usable commit, with the release parser's exact-type semantics.
|
|
44
|
+
* `type` and `scope` come from the usable `focus` commits (those touching a path outside docs/changes/ and docs/plans/), or from
|
|
45
|
+
* every usable commit when `focus` is omitted or has none. The recorded type is the highest-impact chosen commit's type
|
|
46
|
+
* lowercased, or `other` when that is still not purely letters. Null when no commit is usable.
|
|
44
47
|
*/
|
|
45
|
-
function deriveFromCommits(input) {
|
|
48
|
+
function deriveFromCommits(input, focusInput) {
|
|
46
49
|
const commits = usable(input);
|
|
47
50
|
if (commits.length === 0)
|
|
48
51
|
return null;
|
|
52
|
+
const focused = usable(focusInput);
|
|
53
|
+
const chooser = focused.length > 0 ? focused : commits;
|
|
49
54
|
const breaking = commits.some((c) => c.breaking);
|
|
50
|
-
const pick =
|
|
51
|
-
??
|
|
55
|
+
const pick = chooser.find((c) => c.breaking) ?? chooser.find((c) => c.type === 'feat') ?? chooser.find((c) => c.type === 'fix')
|
|
56
|
+
?? chooser.find((c) => c.type === 'perf') ?? chooser[0];
|
|
52
57
|
const counts = new Map();
|
|
53
|
-
for (const c of
|
|
58
|
+
for (const c of chooser)
|
|
54
59
|
if (c.scope)
|
|
55
60
|
counts.set(c.scope, (counts.get(c.scope) ?? 0) + 1);
|
|
56
61
|
const scope = [...counts.entries()].sort((a, b) => b[1] - a[1] || byCodeUnit(a[0], b[0]))[0]?.[0] ?? null;
|
|
@@ -87,6 +92,8 @@ function commitsFromRecords(records) {
|
|
|
87
92
|
const fail = (code, message) => ({ ok: false, diagnostic: { code, message: (0, text_safety_1.clipMessage)(message) } });
|
|
88
93
|
/**
|
|
89
94
|
* Derives from the non-merge commits in mergeBaseSha..HEAD, ignoring commits and files that only touch docs/changes/.
|
|
95
|
+
* R3.3 amended: `type` and `scope` come from the commits that also touch a path outside docs/plans/ (all of them when no
|
|
96
|
+
* conventional commit does), while `semver` and `breaking` cover every commit.
|
|
90
97
|
* `mergeBaseSha` must be a full commit id (the output of `mergeBase`); anything else is `invalid-input`.
|
|
91
98
|
*/
|
|
92
99
|
function deriveFields(root, mergeBaseSha) {
|
|
@@ -103,14 +110,20 @@ function deriveFields(root, mergeBaseSha) {
|
|
|
103
110
|
// (`merge -s ours`), silently dropping real commits from the range.
|
|
104
111
|
// -z ends every commit with NUL, which a message cannot contain: each record is exactly one commit, so a message that
|
|
105
112
|
// embeds the parser's own separator cannot be read as a second commit.
|
|
106
|
-
const
|
|
113
|
+
const logArgs = ['log', ...git_1.LOG_SAFETY_ARGS, '--no-merges', '--full-history', '--reverse', '-z', `--format=${core_1.GIT_LOG_FORMAT}`, '--end-of-options', range, '--', ALL_FILES, EXCLUDE_RECORDS];
|
|
114
|
+
const raw = (0, git_1.runGit)(root, logArgs);
|
|
107
115
|
if (raw === null)
|
|
108
116
|
return fail('git-failed', `git could not read the commits in ${range}`);
|
|
109
117
|
const records = raw.split('\0').filter((record) => record !== '');
|
|
110
118
|
if (records.length === 0)
|
|
111
119
|
return fail('no-commits', `no non-merge commits outside ${types_1.RECORD_DIR}/ in ${range}`);
|
|
112
120
|
const commits = commitsFromRecords(records);
|
|
113
|
-
|
|
121
|
+
// R3.3 amended: plan-only commits do not choose type or scope, so a second log over the same range drops docs/plans/.
|
|
122
|
+
const focusRaw = (0, git_1.runGit)(root, [...logArgs, EXCLUDE_PLANS]);
|
|
123
|
+
if (focusRaw === null)
|
|
124
|
+
return fail('git-failed', `git could not read the commits in ${range}`);
|
|
125
|
+
const focus = commitsFromRecords(focusRaw.split('\0').filter((record) => record !== ''));
|
|
126
|
+
const derived = deriveFromCommits(commits, focus);
|
|
114
127
|
if (derived === null)
|
|
115
128
|
return fail('no-conventional-commits', 'no commit in the range uses a conventional-commit subject (type(scope): subject)');
|
|
116
129
|
const files = (0, git_1.changedFiles)(root, mergeBaseSha, head);
|
|
@@ -3,11 +3,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.LOG_SAFETY_ARGS = void 0;
|
|
6
|
+
exports.DEFAULT_BASES = exports.LOG_SAFETY_ARGS = void 0;
|
|
7
7
|
exports.runGit = runGit;
|
|
8
8
|
exports.runGitBytes = runGitBytes;
|
|
9
9
|
exports.isSafeRef = isSafeRef;
|
|
10
10
|
exports.isSafeRevision = isSafeRevision;
|
|
11
|
+
exports.baseCandidates = baseCandidates;
|
|
11
12
|
exports.resolveBaseCommit = resolveBaseCommit;
|
|
12
13
|
exports.mergeBase = mergeBase;
|
|
13
14
|
exports.isAncestor = isAncestor;
|
|
@@ -81,14 +82,44 @@ function isSafeRevision(rev) {
|
|
|
81
82
|
return parts.length <= 2 && parts.every(isSafeRef);
|
|
82
83
|
}
|
|
83
84
|
/**
|
|
84
|
-
* R3.1:
|
|
85
|
-
*
|
|
85
|
+
* R3.1 (amended 2026-10-19): the default base candidates, remote first so a stale local branch is the last resort. The local
|
|
86
|
+
* branches resolve as full refs (git would otherwise prefer a tag `main` over the branch) but are reported by their short name.
|
|
87
|
+
*/
|
|
88
|
+
exports.DEFAULT_BASES = [
|
|
89
|
+
{ ref: 'refs/remotes/origin/HEAD', name: 'refs/remotes/origin/HEAD' },
|
|
90
|
+
{ ref: 'refs/remotes/origin/main', name: 'refs/remotes/origin/main' },
|
|
91
|
+
{ ref: 'refs/remotes/origin/master', name: 'refs/remotes/origin/master' },
|
|
92
|
+
{ ref: 'refs/heads/main', name: 'main' },
|
|
93
|
+
{ ref: 'refs/heads/master', name: 'master' },
|
|
94
|
+
];
|
|
95
|
+
/**
|
|
96
|
+
* The DEFAULT_BASES whose exact ref exists, in order (one `for-each-ref` for all of them; a broken symref is not listed). Without
|
|
97
|
+
* this check git's rev-parse would fall through to `refs/tags/<ref>`, so a tag literally named `refs/heads/main` could stand in for
|
|
98
|
+
* an absent branch. The patterns are constants; only an exact refname match counts (a pattern also lists the refs below it).
|
|
99
|
+
* Empty when git fails: the defaults then fail closed as unresolved.
|
|
100
|
+
*/
|
|
101
|
+
function existingDefaultBases(root) {
|
|
102
|
+
const out = runGit(root, ['for-each-ref', '--format=%(refname)', '--end-of-options', ...exports.DEFAULT_BASES.map((c) => c.ref)]);
|
|
103
|
+
const present = new Set((out ?? '').split('\n'));
|
|
104
|
+
return exports.DEFAULT_BASES.filter((c) => present.has(c.ref));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* R3.1: the candidates for a base, in order. An explicit base is the only one (no fallback) and keeps git's own resolution;
|
|
108
|
+
* otherwise the DEFAULT_BASES whose exact ref exists. Every caller (`resolveBaseCommit`, `resolveRange`) takes its order from here.
|
|
109
|
+
*/
|
|
110
|
+
function baseCandidates(root, explicit) {
|
|
111
|
+
return explicit !== undefined ? [{ ref: explicit, name: explicit }] : existingDefaultBases(root);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* R3.1: --base, then origin/HEAD, origin/main, origin/master, main, master: the first that names a commit, with that commit's id.
|
|
115
|
+
* An explicit base is the only candidate (no fallback). Null when none does (or the explicit base is unsafe). One `rev-parse` per
|
|
116
|
+
* candidate tried (and, for the defaults, one `for-each-ref`).
|
|
86
117
|
*/
|
|
87
118
|
function resolveBaseCommit(root, explicit) {
|
|
88
|
-
for (const
|
|
89
|
-
const sha = commitSha(root,
|
|
119
|
+
for (const { ref, name } of baseCandidates(root, explicit)) {
|
|
120
|
+
const sha = commitSha(root, ref);
|
|
90
121
|
if (sha !== null)
|
|
91
|
-
return { base:
|
|
122
|
+
return { base: name, sha };
|
|
92
123
|
}
|
|
93
124
|
return null;
|
|
94
125
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.HEAD_MOVED = void 0;
|
|
3
|
+
exports.HEAD_MOVED = exports.BASE_OPTION_HELP = void 0;
|
|
4
4
|
exports.resolveBaseRef = resolveBaseRef;
|
|
5
5
|
exports.resolveRange = resolveRange;
|
|
6
6
|
exports.headMovedFailure = headMovedFailure;
|
|
@@ -9,34 +9,49 @@ const git_1 = require("./git");
|
|
|
9
9
|
const text_safety_1 = require("./text-safety");
|
|
10
10
|
const types_1 = require("./types");
|
|
11
11
|
const REMEDY_BASE = 'pass --base <ref>';
|
|
12
|
+
/** R3.1: the `--base` help text of `change new`, `change validate` and `changes list`: `DEFAULT_BASES` (git.ts) in order, remotes without `refs/remotes/`. */
|
|
13
|
+
exports.BASE_OPTION_HELP = `base branch (default: ${git_1.DEFAULT_BASES.map((c) => c.name.replace(/^refs\/remotes\//, '')).join(', ')})`;
|
|
12
14
|
exports.HEAD_MOVED = { code: 'head-moved', message: 'HEAD moved during derivation' };
|
|
13
15
|
const KINDS = ['ok', 'absent', 'unsafe', 'error'];
|
|
14
16
|
const failure = (code, message, remedy) => ({ ok: false, failure: remedy === undefined ? { code, message } : { code, message, remedy } });
|
|
17
|
+
const unsafeBase = (explicitBase) => failure('invalid-input', `--base "${(0, text_safety_1.esc)(explicitBase)}" is not a safe git ref`);
|
|
15
18
|
/**
|
|
16
|
-
* R3.1/R3.2/R3.5
|
|
17
|
-
*
|
|
19
|
+
* R3.1/R3.2/R3.5, for `changes list`: the base ref: `explicitBase` (checked with `isSafeRef` before git sees it: `invalid-input`),
|
|
20
|
+
* else the first default candidate that names a commit (no merge base is needed); `base-unresolved` when none does. The order of the
|
|
21
|
+
* candidates has one source, `baseCandidates` (git.ts), shared with `resolveRange`.
|
|
18
22
|
*/
|
|
19
23
|
function resolveBaseRef(root, explicitBase) {
|
|
20
24
|
if (explicitBase !== undefined && !(0, git_1.isSafeRef)(explicitBase))
|
|
21
|
-
return
|
|
25
|
+
return unsafeBase(explicitBase);
|
|
22
26
|
const resolved = (0, git_1.resolveBaseCommit)(root, explicitBase);
|
|
23
27
|
return resolved === null ? failure('base-unresolved', 'could not resolve a base branch', REMEDY_BASE) : { ok: true, ...resolved };
|
|
24
28
|
}
|
|
25
29
|
/**
|
|
26
|
-
* R3.1/R3.2/R3.5
|
|
27
|
-
* An explicit base
|
|
30
|
+
* R3.1/R3.2/R3.5, for `change new` and `change validate`: pins HEAD, then resolves the base and its merge base with HEAD, over the
|
|
31
|
+
* same `baseCandidates` (git.ts) as `resolveBaseRef` but also requiring a merge base. An explicit `--base` (checked with `isSafeRef`
|
|
32
|
+
* before git sees it) is the only candidate. Without it (R3.1 amended, Q7), the first default candidate that names a commit AND has
|
|
33
|
+
* a merge base with HEAD: a shallow remote ref that does not reach HEAD gives way to the next.
|
|
34
|
+
* The merge base is computed from the commit id the candidate resolved to, never from its name read a second time (Q9).
|
|
28
35
|
*/
|
|
29
36
|
function resolveRange(root, explicitBase) {
|
|
30
37
|
const head = (0, git_1.headSha)(root);
|
|
31
38
|
if (head === null)
|
|
32
39
|
return failure('git-failed', 'git could not read HEAD');
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
if (explicitBase !== undefined && !(0, git_1.isSafeRef)(explicitBase))
|
|
41
|
+
return unsafeBase(explicitBase);
|
|
42
|
+
let named = null;
|
|
43
|
+
for (const { ref, name } of (0, git_1.baseCandidates)(root, explicitBase)) {
|
|
44
|
+
const sha = (0, git_1.commitSha)(root, ref);
|
|
45
|
+
if (sha === null)
|
|
46
|
+
continue;
|
|
47
|
+
named ??= name;
|
|
48
|
+
const mergeBaseSha = (0, git_1.mergeBase)(root, sha);
|
|
49
|
+
if (mergeBaseSha !== null)
|
|
50
|
+
return { ok: true, head, mergeBase: mergeBaseSha };
|
|
51
|
+
}
|
|
52
|
+
if (named === null)
|
|
53
|
+
return failure('base-unresolved', 'could not resolve a base branch', REMEDY_BASE);
|
|
54
|
+
return failure('base-unresolved', `no merge base between "${(0, text_safety_1.esc)(named)}" and HEAD`, REMEDY_BASE);
|
|
40
55
|
}
|
|
41
56
|
/** `HEAD_MOVED` when HEAD is no longer `head` (or cannot be read), else null: a verdict about a moving target is never given. */
|
|
42
57
|
function headMovedFailure(root, head) {
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.PROCESS_NAME = void 0;
|
|
4
7
|
exports.parseProcessFrontmatter = parseProcessFrontmatter;
|
|
@@ -7,40 +10,155 @@ exports.parseProcessFrontmatter = parseProcessFrontmatter;
|
|
|
7
10
|
// Disciplina heredada literal de core/orchestrators.ts: NUNCA lanza. Un modelo
|
|
8
11
|
// malformado se rechaza con diagnóstico y no invalida al registry que lo
|
|
9
12
|
// contiene ni a los demás (R7.1).
|
|
13
|
+
const js_yaml_1 = __importDefault(require("js-yaml"));
|
|
10
14
|
const frontmatter_1 = require("../frontmatter");
|
|
11
15
|
const types_1 = require("./types");
|
|
12
16
|
/** El discriminador es literal: ningún documento se reconoce como modelo por su
|
|
13
17
|
* cuerpo, sus headings ni su nombre de archivo (R1.2). */
|
|
14
18
|
const DISCRIMINATOR = 'process-model';
|
|
15
|
-
|
|
19
|
+
/** `description` es el único campo opcional: runtimes como Codex se niegan a
|
|
20
|
+
* cargar un `SKILL.md` sin él, y el modelo *es* el `SKILL.md` del skill. */
|
|
21
|
+
const ALLOWED_FIELDS = ['awm', 'schema', 'name', 'status', 'entry_point', 'terminates_to', 'created', 'updated', 'description'];
|
|
22
|
+
/** Límite de Agent Skills que imponen los runtimes que consumen el skill.
|
|
23
|
+
* Se cuenta en code points, no en unidades UTF-16. */
|
|
24
|
+
const DESCRIPTION_MAX = 1024;
|
|
25
|
+
/** Tope del bloque de frontmatter antes de entregarlo al parser YAML: acota el
|
|
26
|
+
* costo de parsear contenido no confiable de un registry. Por encima no se
|
|
27
|
+
* parsea (para `description`, se rechaza con el mensaje del límite de 1024). */
|
|
28
|
+
const FRONTMATTER_MAX = 65536;
|
|
29
|
+
/** Lo que un runtime mostraría mal o usaría para engañar: controles C0 (salvo
|
|
30
|
+
* tab), DEL, C1, separadores U+2028/U+2029, marcas LRM/RLM/ALM, overrides e
|
|
31
|
+
* isolates bidi, y surrogates sueltos (regex en vez de `isWellFormed`: el lib
|
|
32
|
+
* de TS es ES2022). */
|
|
33
|
+
const FORBIDDEN_CHAR = /[\u0000-\u0008\u000A-\u001F\u007F-\u009F\u061C\u200E\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
34
|
+
/** Discriminador indentado: un modelo cuyas claves quedaron con sangría se
|
|
35
|
+
* perdería en silencio como "no es un modelo". */
|
|
36
|
+
const INDENTED_DISCRIMINATOR = /^awm[ \t]*:[ \t]*process-model$/;
|
|
16
37
|
const STATUSES = ['draft', 'active'];
|
|
17
38
|
/** El slug es lo único del modelo que puede viajar dentro de un id del Dashboard
|
|
18
39
|
* (ver sanitize.ts). Por eso se valida acá y no en la frontera de render: si
|
|
19
40
|
* admitiera rutas, markup o `=`, el id dejaría de ser seguro por construcción. */
|
|
20
41
|
exports.PROCESS_NAME = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
21
42
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
43
|
+
/** Caracteres de ancho cero: un valor hecho solo de ellos se ve vacío. */
|
|
44
|
+
const ZERO_WIDTH = /[\u200B-\u200D\u2060\uFEFF]/g;
|
|
22
45
|
/** Lector de pares `clave: valor` de un frontmatter plano. El contrato no admite
|
|
23
|
-
* anidamiento ni block scalars
|
|
24
|
-
*
|
|
46
|
+
* anidamiento ni block scalars: cualquier línea que no sea `clave: valor` es un
|
|
47
|
+
* rechazo, no una interpretación. (El VALOR de `description` sí pasa por el
|
|
48
|
+
* parser YAML, más abajo.) */
|
|
25
49
|
function readPairs(block) {
|
|
26
50
|
const pairs = new Map();
|
|
27
51
|
const problems = [];
|
|
28
|
-
|
|
29
|
-
|
|
52
|
+
const continuations = [];
|
|
53
|
+
// Mismo criterio que YAML, no que JS: se parte en TODO salto de línea YAML
|
|
54
|
+
// (un CR suelto también; si no, `a\rclave: x` sería un par para acá y dos
|
|
55
|
+
// claves para un runtime) y solo se recorta espacio y tab (`\s`/`trim()`
|
|
56
|
+
// comerían NBSP o U+3000, que para YAML son contenido).
|
|
57
|
+
const lines = block.split(/\r\n|\r|\n/);
|
|
58
|
+
for (const [index, raw] of lines.entries()) {
|
|
59
|
+
const line = raw.replace(/^[ \t]+|[ \t]+$/g, '');
|
|
60
|
+
// NEL/LS/PS son saltos de línea para YAML 1.1 (PyYAML) y no para js-yaml:
|
|
61
|
+
// un comentario podría esconder una clave que solo un runtime ve. Se
|
|
62
|
+
// reporta en CUALQUIER línea, comentarios incluidos, y solo por posición.
|
|
63
|
+
if (/[\u0085\u2028\u2029]/.test(raw))
|
|
64
|
+
problems.push(`frontmatter line ${index + 1} contains a YAML 1.1 line break (NEL, LS or PS)`);
|
|
30
65
|
if (line === '' || line.startsWith('#'))
|
|
31
66
|
continue;
|
|
32
|
-
|
|
67
|
+
// El contrato es un mapa PLANO: una línea que empieza con espacio o tab
|
|
68
|
+
// es continuación de un valor multilínea, diga lo que diga. Si se
|
|
69
|
+
// recortara antes de mirar, ` KEY: x` pasaría por campo (y su nombre
|
|
70
|
+
// terminaría en un diagnóstico).
|
|
71
|
+
const indented = /^[ \t]/.test(raw);
|
|
72
|
+
if (indented)
|
|
73
|
+
continuations.push({ line: index + 1, text: line });
|
|
74
|
+
// `[\s\S]` y no `.`: un U+2028/U+2029 dentro del valor no lo vuelve
|
|
75
|
+
// "no-par" (la línea ya viene partida); lo rechaza el chequeo de controles.
|
|
76
|
+
const m = indented ? null : /^([A-Za-z_][A-Za-z0-9_]*)[ \t]*:[ \t]*([\s\S]*)$/.exec(line);
|
|
77
|
+
// Se reporta la POSICIÓN (1-based dentro del bloque), nunca el
|
|
78
|
+
// contenido: una línea suelta suele ser la continuación de un valor
|
|
79
|
+
// multilínea, y los diagnósticos no transportan valores de campos.
|
|
33
80
|
if (!m) {
|
|
34
|
-
problems.push(`line is not a "key: value" pair
|
|
81
|
+
problems.push(`frontmatter line ${index + 1} is not a "key: value" pair (multi-line values are not supported)`);
|
|
35
82
|
continue;
|
|
36
83
|
}
|
|
37
84
|
if (pairs.has(m[1])) {
|
|
38
85
|
problems.push(`duplicate field ${JSON.stringify(m[1])}`);
|
|
39
86
|
continue;
|
|
40
87
|
}
|
|
41
|
-
pairs.set(m[1], m[2]
|
|
88
|
+
pairs.set(m[1], m[2]);
|
|
42
89
|
}
|
|
43
|
-
return { pairs, problems };
|
|
90
|
+
return { pairs, problems, continuations };
|
|
91
|
+
}
|
|
92
|
+
/** Carga el bloque de frontmatter COMPLETO con js-yaml (schema por defecto),
|
|
93
|
+
* exactamente como lo lee un runtime. Por encima del tope no se parsea. NUNCA
|
|
94
|
+
* lanza: un error de js-yaml es `invalid`. */
|
|
95
|
+
function loadBlock(block) {
|
|
96
|
+
if (block.length > FRONTMATTER_MAX)
|
|
97
|
+
return { kind: 'too-long' };
|
|
98
|
+
try {
|
|
99
|
+
return { kind: 'loaded', doc: js_yaml_1.default.load(block) };
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return { kind: 'invalid' };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function isPlainObject(doc) {
|
|
106
|
+
return doc !== null && typeof doc === 'object' && Object.getPrototypeOf(doc) === Object.prototype;
|
|
107
|
+
}
|
|
108
|
+
/** Valida `description` leyéndola del bloque entero con el parser YAML real, no
|
|
109
|
+
* de la salida de readPairs (ya recortada y partida en `:`, que difiere de lo
|
|
110
|
+
* que lee un runtime). Ni el valor ni el texto del error de js-yaml (que puede
|
|
111
|
+
* citar contenido) llegan al problema. */
|
|
112
|
+
/** ¿El valor crudo de la línea, cargado SOLO, da el mismo string que el bloque
|
|
113
|
+
* entero? Si no, YAML lo plegó con líneas siguientes (p. ej. una comilla que
|
|
114
|
+
* sigue abierta a través de una línea `# …`). NUNCA lanza. */
|
|
115
|
+
function readsSameAlone(raw, value) {
|
|
116
|
+
let alone;
|
|
117
|
+
try {
|
|
118
|
+
alone = js_yaml_1.default.load(`description: ${raw}`);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
return isPlainObject(alone) && alone.description === value;
|
|
124
|
+
}
|
|
125
|
+
function checkDescription(raw, block, keys) {
|
|
126
|
+
const singleLine = { problem: '"description" must be a single-line string (block scalars are not supported in a process model)' };
|
|
127
|
+
// El prefijo alcanza: todo header de block scalar (bien o mal formado)
|
|
128
|
+
// empieza con `>` o `|`.
|
|
129
|
+
if (/^[>|]/.test(raw))
|
|
130
|
+
return singleLine;
|
|
131
|
+
const tooLong = { problem: `"description" must be at most ${DESCRIPTION_MAX} characters` };
|
|
132
|
+
const loaded = loadBlock(block);
|
|
133
|
+
if (loaded.kind === 'too-long')
|
|
134
|
+
return tooLong;
|
|
135
|
+
if (loaded.kind === 'invalid' || !isPlainObject(loaded.doc))
|
|
136
|
+
return { problem: '"description" cannot be read: the frontmatter is not valid YAML' };
|
|
137
|
+
const doc = loaded.doc;
|
|
138
|
+
// Si YAML lee otras claves de nivel superior que readPairs (un CR suelto,
|
|
139
|
+
// una comilla que se traga la línea siguiente), lo validado acá no es lo
|
|
140
|
+
// que verá un runtime. Ni claves ni valores van al problema.
|
|
141
|
+
const yamlKeys = Object.keys(doc);
|
|
142
|
+
if (yamlKeys.length !== keys.size || yamlKeys.some((k) => !keys.has(k))) {
|
|
143
|
+
return { problem: '"description" cannot be read: the frontmatter does not read the same as YAML (check quotes and line breaks)' };
|
|
144
|
+
}
|
|
145
|
+
const value = doc.description;
|
|
146
|
+
const empty = { problem: '"description" must not be empty' };
|
|
147
|
+
if (value === null || value === undefined)
|
|
148
|
+
return empty;
|
|
149
|
+
if (typeof value !== 'string') {
|
|
150
|
+
return { problem: '"description" must be a string (quote values YAML reads as a boolean, a number, a date or a collection)' };
|
|
151
|
+
}
|
|
152
|
+
if (value.replace(ZERO_WIDTH, '').trim() === '')
|
|
153
|
+
return empty;
|
|
154
|
+
if ([...value].length > DESCRIPTION_MAX)
|
|
155
|
+
return tooLong;
|
|
156
|
+
if (FORBIDDEN_CHAR.test(value))
|
|
157
|
+
return { problem: '"description" must not contain control, line-break or bidirectional-override characters' };
|
|
158
|
+
if (!readsSameAlone(raw, value))
|
|
159
|
+
return singleLine;
|
|
160
|
+
// Se guarda tal cual lo devuelve YAML: sin trim, con NBSP/BOM intactos.
|
|
161
|
+
return { value };
|
|
44
162
|
}
|
|
45
163
|
function parseProcessFrontmatter(source, file) {
|
|
46
164
|
let block;
|
|
@@ -52,7 +170,22 @@ function parseProcessFrontmatter(source, file) {
|
|
|
52
170
|
}
|
|
53
171
|
if (block === null)
|
|
54
172
|
return { diagnostics: [] };
|
|
55
|
-
const { pairs, problems } = readPairs(block);
|
|
173
|
+
const { pairs, problems, continuations } = readPairs(block);
|
|
174
|
+
// Un modelo con las claves indentadas no tiene `awm` de nivel superior y se
|
|
175
|
+
// perdería en silencio como "otro documento". Se nombra solo la línea.
|
|
176
|
+
// Dispara con el prefiltro barato y, además, si el bloque NO carga como YAML
|
|
177
|
+
// (sangría parcial o con tab; por encima del tope cuenta como no cargable)
|
|
178
|
+
// o carga con `awm: process-model` en su nivel superior. Si carga sin `awm`
|
|
179
|
+
// arriba (mención bajo `metadata:` o dentro de un block scalar), silencio.
|
|
180
|
+
if (!pairs.has('awm')) {
|
|
181
|
+
const indentedAwm = continuations.find((c) => INDENTED_DISCRIMINATOR.test(c.text));
|
|
182
|
+
const loaded = indentedAwm ? loadBlock(block) : undefined;
|
|
183
|
+
const fires = loaded !== undefined
|
|
184
|
+
&& (loaded.kind !== 'loaded' || (isPlainObject(loaded.doc) && loaded.doc.awm === DISCRIMINATOR));
|
|
185
|
+
if (indentedAwm && fires) {
|
|
186
|
+
return { diagnostics: [`${file}: invalid process model — frontmatter keys must not be indented (frontmatter line ${indentedAwm.line})`] };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
56
189
|
// Sin discriminador NO es un modelo de proceso: no es un error, es otro
|
|
57
190
|
// documento. Devolver diagnóstico acá inundaría de ruido a todo registry
|
|
58
191
|
// (cada SKILL.md normal caería en esta rama).
|
|
@@ -96,6 +229,15 @@ function parseProcessFrontmatter(source, file) {
|
|
|
96
229
|
if (value !== '' && !DATE.test(value))
|
|
97
230
|
problems.push(`"${field}" must be YYYY-MM-DD`);
|
|
98
231
|
}
|
|
232
|
+
let description;
|
|
233
|
+
const rawDescription = pairs.get('description');
|
|
234
|
+
if (rawDescription !== undefined) {
|
|
235
|
+
const checked = checkDescription(rawDescription, block, new Set(pairs.keys()));
|
|
236
|
+
if ('problem' in checked)
|
|
237
|
+
problems.push(checked.problem);
|
|
238
|
+
else
|
|
239
|
+
description = checked.value;
|
|
240
|
+
}
|
|
99
241
|
if (problems.length > 0) {
|
|
100
242
|
return { diagnostics: [`${file}: invalid process model — ${problems.join('; ')}`] };
|
|
101
243
|
}
|
|
@@ -103,6 +245,7 @@ function parseProcessFrontmatter(source, file) {
|
|
|
103
245
|
model: {
|
|
104
246
|
schema, name, status: status, entryPoint: entryPointRaw === 'true',
|
|
105
247
|
terminatesTo, created: pairs.get('created'), updated: pairs.get('updated'),
|
|
248
|
+
...(description !== undefined ? { description } : {}),
|
|
106
249
|
},
|
|
107
250
|
diagnostics: [],
|
|
108
251
|
};
|
|
@@ -781,7 +781,7 @@ describe('awm change new: base and derivation diagnostics (R3.1, R3.2, R3.5)', (
|
|
|
781
781
|
receiptForHead();
|
|
782
782
|
expectFailure(run('ok', { base: 'nope' }), 'base-unresolved', 'pass --base <ref>');
|
|
783
783
|
});
|
|
784
|
-
test('no
|
|
784
|
+
test('no default base resolves: base-unresolved', () => {
|
|
785
785
|
repo.git('branch', '-m', 'main', 'trunk');
|
|
786
786
|
receiptForHead();
|
|
787
787
|
expectFailure(run('ok'), 'base-unresolved', 'pass --base <ref>');
|
|
@@ -794,6 +794,29 @@ describe('awm change new: base and derivation diagnostics (R3.1, R3.2, R3.5)', (
|
|
|
794
794
|
run('change-record', { base: 'mid' });
|
|
795
795
|
expect(frontmatter(read())).toMatchObject({ type: 'fix', scope: 'core', semver: 'patch', modules: ['cli/src'] });
|
|
796
796
|
});
|
|
797
|
+
test('a #228-shaped branch (plan commits first, then ci(release) commits) records type ci and scope release', () => {
|
|
798
|
+
repo.git('checkout', '-q', '-b', 'ci/shard', 'main');
|
|
799
|
+
repo.commit({ 'docs/plans/p.md': '1' }, 'docs(plans): plan it');
|
|
800
|
+
repo.commit({ '.github/workflows/ci.yml': '1' }, 'ci(release): shard');
|
|
801
|
+
repo.commit({ 'docs/plans/p.md': '2' }, 'docs(plans): amend A1');
|
|
802
|
+
repo.commit({ 'docs/plans/p.md': '3' }, 'docs(plans): amend A2');
|
|
803
|
+
repo.commit({ '.github/workflows/release.yml': '1' }, 'ci(release): gate it');
|
|
804
|
+
receiptForHead();
|
|
805
|
+
expect(run('change-record').code).toBe(0);
|
|
806
|
+
expect(frontmatter(read())).toMatchObject({ type: 'ci', scope: 'release', semver: 'none', breaking: false, modules: ['.github/workflows', 'docs/plans'] });
|
|
807
|
+
});
|
|
808
|
+
test('without origin/HEAD, a current origin/main is the base, not a stale local main', () => {
|
|
809
|
+
repo.git('checkout', '-q', '-b', 'upstream', 'main');
|
|
810
|
+
repo.commit({ 'other/u.ts': '1' }, 'feat(other)!: merged upstream');
|
|
811
|
+
repo.git('update-ref', 'refs/remotes/origin/main', 'upstream');
|
|
812
|
+
repo.git('checkout', '-q', '-b', 'feat/y', 'upstream');
|
|
813
|
+
repo.git('branch', '-D', 'upstream');
|
|
814
|
+
repo.commit({ 'cli/src/m.ts': '1' }, 'fix(core): mine');
|
|
815
|
+
receiptForHead();
|
|
816
|
+
expect(run('change-record').code).toBe(0);
|
|
817
|
+
// Against the stale local main, the upstream breaking feat would be in the range.
|
|
818
|
+
expect(frontmatter(read())).toMatchObject({ type: 'fix', scope: 'core', semver: 'patch', breaking: false, modules: ['cli/src'] });
|
|
819
|
+
});
|
|
797
820
|
test('a branch with no commits beyond the base is no-commits', () => {
|
|
798
821
|
repo.git('checkout', '-q', 'main');
|
|
799
822
|
expectFailure(run('ok'), 'no-commits');
|
|
@@ -1096,6 +1119,10 @@ describe('registerChangeCommand', () => {
|
|
|
1096
1119
|
const change = program().commands.find((c) => c.name() === 'change');
|
|
1097
1120
|
const sub = change?.commands.find((c) => c.name() === 'new');
|
|
1098
1121
|
expect(sub?.options.map((o) => o.long).sort()).toEqual(['--base', '--issue', '--model', '--plan', '--refresh', '--session']);
|
|
1122
|
+
for (const name of ['new', 'validate']) {
|
|
1123
|
+
const base = change?.commands.find((c) => c.name() === name)?.options.find((o) => o.long === '--base');
|
|
1124
|
+
expect(base?.description).toBe('base branch (default: origin/HEAD, origin/main, origin/master, main, master)');
|
|
1125
|
+
}
|
|
1099
1126
|
});
|
|
1100
1127
|
test('the change command describes both of its subcommands', () => {
|
|
1101
1128
|
expect(program().commands.find((c) => c.name() === 'change')?.description()).toBe('create and validate the Change Record of the current branch');
|
|
@@ -334,7 +334,7 @@ describe('base resolution (R3.1, R3.2, R3.5)', () => {
|
|
|
334
334
|
expectFailure(r, 'base-unresolved');
|
|
335
335
|
expect(r.stderr).toContain('remedy: pass --base <ref>');
|
|
336
336
|
});
|
|
337
|
-
test('no
|
|
337
|
+
test('no default base resolves: base-unresolved', () => {
|
|
338
338
|
repo.git('branch', '-m', 'main', 'trunk');
|
|
339
339
|
expectFailure((0, change_1.runChangeValidate)(repo.root, undefined, {}), 'base-unresolved');
|
|
340
340
|
});
|
|
@@ -794,7 +794,7 @@ describe('R4.5 operational failures under --json', () => {
|
|
|
794
794
|
['path-invalid (hostile path)', 'path-invalid', () => (0, change_1.runChangeValidate)(repo.root, `docs/changes/\u001b${'z\n'.repeat(400)}.md`, J)],
|
|
795
795
|
['git-failed', 'git-failed', () => { jest.spyOn(gitModule, 'headSha').mockReturnValue(null); return (0, change_1.runChangeValidate)(repo.root, undefined, J); }],
|
|
796
796
|
['flag-invalid', 'flag-invalid', () => (0, change_1.runChangeValidate)(repo.root, undefined, (0, helpers_1.bad)({ ...J, base: 7 }))],
|
|
797
|
-
['internal-error', 'internal-error', () => { jest.spyOn(gitModule, '
|
|
797
|
+
['internal-error', 'internal-error', () => { jest.spyOn(gitModule, 'baseCandidates').mockImplementation(() => { throw new RangeError('x'); }); return (0, change_1.runChangeValidate)(repo.root, undefined, J); }],
|
|
798
798
|
];
|
|
799
799
|
test.each(cases)('%s prints one JSON line of the report shape on stdout and exits 2', (_name, code, call) => {
|
|
800
800
|
const r = call();
|
|
@@ -911,7 +911,7 @@ describe('one derivation, one ancestry check and one late-file listing per verif
|
|
|
911
911
|
let result;
|
|
912
912
|
const calls = gitCalls(() => { result = run(); });
|
|
913
913
|
expect(report(result)).toMatchObject({ state: 'valid', records: [{ state: 'valid' }, { state: 'valid' }] });
|
|
914
|
-
expect(calls.filter((a) => a.includes('--no-merges'))).toHaveLength(
|
|
914
|
+
expect(calls.filter((a) => a.includes('--no-merges'))).toHaveLength(2); // the derivation's full log and its focus log
|
|
915
915
|
expect(calls.filter((a) => a.includes('--is-ancestor'))).toHaveLength(1);
|
|
916
916
|
expect(calls.filter((a) => a.includes('diff') && a.includes(verified))).toHaveLength(1); // the late-file listing
|
|
917
917
|
});
|
|
@@ -389,6 +389,7 @@ describe('registerChangesCommand', () => {
|
|
|
389
389
|
test('registers changes list with its documented flags', () => {
|
|
390
390
|
expect(sub()?.options.map((o) => o.long).sort()).toEqual(['--base', '--format', '--since']);
|
|
391
391
|
expect(sub()?.registeredArguments).toEqual([]);
|
|
392
|
+
expect(sub()?.options.find((o) => o.long === '--base')?.description).toBe('base branch (default: origin/HEAD, origin/main, origin/master, main, master)');
|
|
392
393
|
});
|
|
393
394
|
test('a run prints the result and leaves the exit code alone', async () => {
|
|
394
395
|
history();
|
|
@@ -42,6 +42,10 @@ describe('awm process show --json', () => {
|
|
|
42
42
|
expect(parsed).toEqual(expect.objectContaining({ name: 'mi-proceso', schema: 1, status: 'draft' }));
|
|
43
43
|
expect(parsed.body.routing).toEqual([{ when: 'Al empezar', requiredState: '', goesTo: 'OP-1.1', endsAt: 'SG-1' }]);
|
|
44
44
|
});
|
|
45
|
+
it('emite description cuando el modelo la trae', () => {
|
|
46
|
+
const parsed = JSON.parse((0, process_1.runProcessShow)({ models: [model({ description: 'Use when tracing' })], diagnostics: [] }, 'mi-proceso', true).stdout);
|
|
47
|
+
expect(parsed.description).toBe('Use when tracing');
|
|
48
|
+
});
|
|
45
49
|
it('el JSON no filtra el path del filesystem del registry', () => {
|
|
46
50
|
const parsed = JSON.parse((0, process_1.runProcessShow)({ models: [model()], diagnostics: [] }, 'mi-proceso', true).stdout);
|
|
47
51
|
expect(JSON.stringify(parsed)).not.toContain('/r/skills');
|