@christang/keel 5.39.0 → 5.46.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/README.md +12 -2
- package/assets/bootstrap/AGENTS.md +1 -1
- package/package.json +4 -2
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/plugins/keel/skills/keel-run-single-task-goal/SKILL.md +1 -1
- package/scripts/install_to_repo.py +74 -4
- package/scripts/validate_plugin.py +1775 -117
- package/src/core/config.js +7 -1
- package/src/core/gates.js +138 -26
- package/src/core/task-contract.js +24 -5
package/src/core/config.js
CHANGED
|
@@ -7,7 +7,13 @@ const path = require("path");
|
|
|
7
7
|
// closed so an entry outside it can be reported by name: a free-form grant
|
|
8
8
|
// cannot tell a typo from a decision, and silently dropping one leaves the
|
|
9
9
|
// author believing they authorized something they did not.
|
|
10
|
-
const STANDING_AUTHORIZATION_ACTIONS = [
|
|
10
|
+
const STANDING_AUTHORIZATION_ACTIONS = [
|
|
11
|
+
"commit",
|
|
12
|
+
"push",
|
|
13
|
+
"release",
|
|
14
|
+
"archive",
|
|
15
|
+
"continuation",
|
|
16
|
+
];
|
|
11
17
|
|
|
12
18
|
// The closed vocabulary of capability tiers a repository may declare for a
|
|
13
19
|
// delegated task. The names describe the capability the work requires, never
|
package/src/core/gates.js
CHANGED
|
@@ -244,7 +244,7 @@ function taskStart(repo, options) {
|
|
|
244
244
|
const compiled = compileTaskContract(repo, selection.change, task);
|
|
245
245
|
const problems = [
|
|
246
246
|
...compiled.diagnostics,
|
|
247
|
-
...invalidationProblems(repo, selection.content, selection.tasks),
|
|
247
|
+
...invalidationProblems(repo, selection.content, selection.tasks, selection.change),
|
|
248
248
|
];
|
|
249
249
|
// Recording the current fingerprint is idempotent: --record replaces the
|
|
250
250
|
// selected task's Contract anchor whatever it holds, so reauthorizing a task
|
|
@@ -394,11 +394,21 @@ const TRACKER_REFERENCE = /\bhttps?:\/\/\S/i;
|
|
|
394
394
|
|
|
395
395
|
// The owner forms, stated once so every refusal that lists them agrees with
|
|
396
396
|
// every other and with what the checks below actually accept.
|
|
397
|
+
//
|
|
398
|
+
// What each form is worth is part of the sentence, because a list of accepted
|
|
399
|
+
// spellings reads as a list of verified guarantees. A path is checked for
|
|
400
|
+
// existence at the moment it is cited and never again; a tracker reference is
|
|
401
|
+
// accepted on its shape, because a gate that fetched one would stop being
|
|
402
|
+
// local and offline, which is the property its verdict rests on. Leaving that
|
|
403
|
+
// unsaid is how an author comes to believe a check ran that did not (#100).
|
|
397
404
|
const DURABLE_OWNER_FORMS =
|
|
398
405
|
"an absolute `https://…` tracker reference, or any repo-relative path that "
|
|
399
|
-
+ "exists —
|
|
400
|
-
+ "
|
|
401
|
-
+ "than an owner"
|
|
406
|
+
+ "exists and outlives this change — an archived `openspec/changes/archive/…` "
|
|
407
|
+
+ "artifact, `keel/archive/…`, or the repository's own ledger; "
|
|
408
|
+
+ "`keel/HANDOFF.md` is a pointer override rather than an owner. A path is "
|
|
409
|
+
+ "checked for existence when it is cited and is not re-checked afterwards, "
|
|
410
|
+
+ "and a tracker reference is accepted on its shape because a gate runs "
|
|
411
|
+
+ "offline and never fetches one";
|
|
402
412
|
|
|
403
413
|
// Trailing punctuation a declared path can abut in prose. ASCII sentence marks
|
|
404
414
|
// and their CJK counterparts both belong here: once the extractor stops
|
|
@@ -407,9 +417,23 @@ const DURABLE_OWNER_FORMS =
|
|
|
407
417
|
// swallows it and the gate looks for a file that cannot exist.
|
|
408
418
|
const DECLARED_PATH_TRAILING = /[.,;:!?)\]}"'\u2019\u201d\u3002\uff0c\u3001\uff1b\uff1a\uff01\uff1f\uff09\u3011\u300b\u300d\u300f]+$/;
|
|
409
419
|
|
|
410
|
-
// A
|
|
411
|
-
//
|
|
412
|
-
//
|
|
420
|
+
// A file at the repository root carries no separator, and nine of them sit at
|
|
421
|
+
// this repository's root — `AGENTS.md`, `README.md`, `package.json` among them
|
|
422
|
+
// — every one a legitimate owner. Requiring a separator refused them with "it
|
|
423
|
+
// names neither a check nor a path", for a path whose file exists, and left
|
|
424
|
+
// the author only `./AGENTS.md`: a concession to this function rather than a
|
|
425
|
+
// path anyone meant (issue #107).
|
|
426
|
+
//
|
|
427
|
+
// The shape is a trailing extension beginning with a letter. That is what
|
|
428
|
+
// keeps a bare word unrecognized — `Durable owner: pending` reported as a
|
|
429
|
+
// missing file would send the author to create one — and what keeps a version
|
|
430
|
+
// string out, since `5.44.0` would otherwise read as `44` with extension `0`,
|
|
431
|
+
// and authors write versions in prose beside an owner.
|
|
432
|
+
const ROOT_FILE_NAME = /^[^\s`]+\.[A-Za-z][A-Za-z0-9]{0,7}$/;
|
|
433
|
+
|
|
434
|
+
// A declared path is a run of non-whitespace. What ends a path is whitespace;
|
|
435
|
+
// what a path is *made of* is the filesystem's business, and answering the
|
|
436
|
+
// first question with the second is what refused
|
|
413
437
|
// `notes/note-006-转岗最难的不是流程/note.md` by reporting that
|
|
414
438
|
// `notes/note-006-` does not exist — a path nobody wrote (issue #60). It is the
|
|
415
439
|
// same class as #40 on the worktree-reading side, which survived because that
|
|
@@ -425,24 +449,66 @@ function declaredPath(value) {
|
|
|
425
449
|
const quoted = text.match(/`([^`\n]*\/[^`\n]*)`/);
|
|
426
450
|
if (quoted) return quoted[1].trim() || null;
|
|
427
451
|
const bare = text.match(/[^\s`]+\/[^\s`]+/);
|
|
428
|
-
if (
|
|
429
|
-
|
|
452
|
+
if (bare) return bare[0].replace(DECLARED_PATH_TRAILING, "") || null;
|
|
453
|
+
// The separator form is tried first and is unchanged, so nothing that
|
|
454
|
+
// resolves today resolves differently. The trim runs before the shape is
|
|
455
|
+
// judged, so a root file ending a sentence is still a root file.
|
|
456
|
+
for (const token of text.split(/\s+/)) {
|
|
457
|
+
const trimmed = token.replace(DECLARED_PATH_TRAILING, "");
|
|
458
|
+
if (trimmed && ROOT_FILE_NAME.test(trimmed)) return trimmed;
|
|
459
|
+
}
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// A path inside the selected change's own directory exists now and cannot
|
|
464
|
+
// exist later: archiving moves `openspec/changes/<name>/` under
|
|
465
|
+
// `openspec/changes/archive/`, so the one guarantee the gate offers expires in
|
|
466
|
+
// the next step of the workflow that accepted it. Measured in this repository,
|
|
467
|
+
// 10 declarations name such a path and all 10 are dead; the field report
|
|
468
|
+
// measured 35 of 36 (issue #100). Existence is necessary and not sufficient —
|
|
469
|
+
// the same line `keel/HANDOFF.md` already sits on.
|
|
470
|
+
//
|
|
471
|
+
// The rule is the directory, not the file: every file under it moves together,
|
|
472
|
+
// and naming `design.md` would refuse one spelling of one instance. And it is
|
|
473
|
+
// *this* change's directory, not change directories in general — the protocol
|
|
474
|
+
// names a new OpenSpec change as a legitimate owner of deferred work, and no
|
|
475
|
+
// measured pointer has that shape.
|
|
476
|
+
function insideOwnChangeDirectory(candidate, change) {
|
|
477
|
+
if (!change || !candidate) return false;
|
|
478
|
+
const prefix = `openspec/changes/${change}/`;
|
|
479
|
+
return String(candidate).replace(/^\.\//, "").startsWith(prefix);
|
|
430
480
|
}
|
|
431
481
|
|
|
432
482
|
// Classify a declared `Durable owner:` value. A gate runs without network, so a
|
|
433
483
|
// URL is accepted on shape alone; a path is the one form it can actually check,
|
|
434
|
-
// and checking it is stricter than the prefix whitelist this replaced
|
|
435
|
-
|
|
484
|
+
// and checking it is stricter than the prefix whitelist this replaced — for as
|
|
485
|
+
// long as the path outlives the change, which `insideOwnChangeDirectory` is
|
|
486
|
+
// there to decide.
|
|
487
|
+
function durableOwnerVerdict(repo, value, change) {
|
|
436
488
|
const owner = String(value || "").trim();
|
|
437
489
|
if (!owner) return { ok: false, reason: "unrecognized" };
|
|
438
490
|
if (/keel\/HANDOFF\.md/i.test(owner)) return { ok: false, reason: "handoff" };
|
|
439
491
|
if (TRACKER_REFERENCE.test(owner)) return { ok: true };
|
|
440
492
|
const candidate = declaredPath(owner);
|
|
441
493
|
if (!candidate) return { ok: false, reason: "unrecognized" };
|
|
494
|
+
if (insideOwnChangeDirectory(candidate, change)) {
|
|
495
|
+
return { ok: false, reason: "transient", path: candidate };
|
|
496
|
+
}
|
|
442
497
|
if (fs.existsSync(path.join(repo, candidate))) return { ok: true };
|
|
443
498
|
return { ok: false, reason: "missing", path: candidate };
|
|
444
499
|
}
|
|
445
500
|
|
|
501
|
+
// The one sentence every transient refusal makes, so the three consumers say
|
|
502
|
+
// it the same way. It names the cause (the directory moves) rather than the
|
|
503
|
+
// symptom, because the author cannot repair a spelling problem they do not
|
|
504
|
+
// have.
|
|
505
|
+
function transientOwnerMessage(candidate) {
|
|
506
|
+
return `\`${candidate}\` is inside this change's own directory, which moves `
|
|
507
|
+
+ "to `openspec/changes/archive/` when the change is archived — the file "
|
|
508
|
+
+ "exists now and the pointer is guaranteed to break. Name something that "
|
|
509
|
+
+ `outlives the change: ${DURABLE_OWNER_FORMS}.`;
|
|
510
|
+
}
|
|
511
|
+
|
|
446
512
|
// A finding has three dispositions and the gate recognized two. One found and
|
|
447
513
|
// fixed inside the task recording it has no owner to name and nothing to
|
|
448
514
|
// discard, so the only text that passed was `Discard reason:` — filing a repair
|
|
@@ -469,7 +535,7 @@ const RESOLVED_HERE = /\bresolved here\s*:[ \t]*(\S*)/gi;
|
|
|
469
535
|
// it or the artifact that shows it. A bare marker is refused because a
|
|
470
536
|
// disposition that asserts its own conclusion would be a way out of the other
|
|
471
537
|
// two, and the third state would decay into the easiest exit.
|
|
472
|
-
function resolutionEvidenceVerdict(repo, value, commands) {
|
|
538
|
+
function resolutionEvidenceVerdict(repo, value, commands, change) {
|
|
473
539
|
const evidence = String(value || "").trim();
|
|
474
540
|
if (!evidence) return { ok: false, reason: "empty" };
|
|
475
541
|
// The tracker form is tested before the path form: a URL contains something
|
|
@@ -484,6 +550,12 @@ function resolutionEvidenceVerdict(repo, value, commands) {
|
|
|
484
550
|
}
|
|
485
551
|
const candidate = declaredPath(evidence);
|
|
486
552
|
if (!candidate) return { ok: false, reason: "unrecognized" };
|
|
553
|
+
// Resolution evidence is a file like any other and moves with the directory
|
|
554
|
+
// holding it, so it earns the same verdict rather than a second answer to
|
|
555
|
+
// the same question.
|
|
556
|
+
if (insideOwnChangeDirectory(candidate, change)) {
|
|
557
|
+
return { ok: false, reason: "transient", path: candidate };
|
|
558
|
+
}
|
|
487
559
|
if (fs.existsSync(path.join(repo, candidate))) return { ok: true };
|
|
488
560
|
return { ok: false, reason: "missing", path: candidate };
|
|
489
561
|
}
|
|
@@ -504,13 +576,16 @@ function resolutionEvidenceMessage(verdict) {
|
|
|
504
576
|
if (verdict.reason === "unknown-check") {
|
|
505
577
|
return `${lead}${verdict.label} is not a check this task declares.${tail}`;
|
|
506
578
|
}
|
|
579
|
+
if (verdict.reason === "transient") {
|
|
580
|
+
return `${lead}${transientOwnerMessage(verdict.path)}${tail}`;
|
|
581
|
+
}
|
|
507
582
|
if (verdict.reason === "missing") {
|
|
508
583
|
return `${lead}\`${verdict.path}\` does not exist.${tail}`;
|
|
509
584
|
}
|
|
510
585
|
return `${lead}it names neither a check nor a path.${tail}`;
|
|
511
586
|
}
|
|
512
587
|
|
|
513
|
-
function findingOwnerIsDurable(repo, findings) {
|
|
588
|
+
function findingOwnerIsDurable(repo, findings, change) {
|
|
514
589
|
if (/keel\/HANDOFF\.md/i.test(findings)) return false;
|
|
515
590
|
if (/\b(?:explicit\s+)?discard (?:reason|rationale)\s*:/i.test(findings)) {
|
|
516
591
|
return true;
|
|
@@ -520,11 +595,17 @@ function findingOwnerIsDurable(repo, findings) {
|
|
|
520
595
|
// prose, and a finding that merely mentions the source file it concerns has
|
|
521
596
|
// not thereby given that finding an owner.
|
|
522
597
|
const declared = findings.match(/Durable owner:\s*(\S[^\n]*)/i);
|
|
523
|
-
if (declared) return durableOwnerVerdict(repo, declared[1]).ok;
|
|
598
|
+
if (declared) return durableOwnerVerdict(repo, declared[1], change).ok;
|
|
524
599
|
const artifact = findings.match(
|
|
525
600
|
/\b(openspec\/changes\/[A-Za-z0-9][A-Za-z0-9._-]*\/(?:proposal|design|tasks)\.md)(?:#\d+(?:\.\d+)*)?/i
|
|
526
601
|
);
|
|
527
|
-
if (
|
|
602
|
+
if (
|
|
603
|
+
artifact
|
|
604
|
+
&& !insideOwnChangeDirectory(artifact[1], change)
|
|
605
|
+
&& fs.existsSync(path.join(repo, artifact[1]))
|
|
606
|
+
) {
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
528
609
|
// Same extractor, scoped to the archive prefix: the segment after
|
|
529
610
|
// `keel/archive/` is a path like any other and was equally ASCII-bound.
|
|
530
611
|
const archive = findings.match(/keel\/archive\/[^\s`]*/i);
|
|
@@ -741,7 +822,7 @@ function attributeChanged(repo, task, changedList, contract, change, tasks) {
|
|
|
741
822
|
};
|
|
742
823
|
}
|
|
743
824
|
|
|
744
|
-
function completionChecks(repo, task, contract = null, changeVerify = null) {
|
|
825
|
+
function completionChecks(repo, task, contract = null, changeVerify = null, change = null) {
|
|
745
826
|
const problems = [];
|
|
746
827
|
const commands = contract
|
|
747
828
|
? contract.capsule.verification.commands.map((item) => item.label)
|
|
@@ -900,14 +981,14 @@ function completionChecks(repo, task, contract = null, changeVerify = null) {
|
|
|
900
981
|
const resolved = [...reviewFields.Findings.matchAll(RESOLVED_HERE)];
|
|
901
982
|
if (resolved.length > 0) {
|
|
902
983
|
for (const claim of resolved) {
|
|
903
|
-
const verdict = resolutionEvidenceVerdict(repo, claim[1], commands);
|
|
984
|
+
const verdict = resolutionEvidenceVerdict(repo, claim[1], commands, change);
|
|
904
985
|
if (verdict.ok) continue;
|
|
905
986
|
problems.push(
|
|
906
987
|
problem("finding-resolution-evidence", resolutionEvidenceMessage(verdict))
|
|
907
988
|
);
|
|
908
989
|
break;
|
|
909
990
|
}
|
|
910
|
-
} else if (!findingOwnerIsDurable(repo, reviewFields.Findings)) {
|
|
991
|
+
} else if (!findingOwnerIsDurable(repo, reviewFields.Findings, change)) {
|
|
911
992
|
problems.push(
|
|
912
993
|
problem(
|
|
913
994
|
"finding-owner",
|
|
@@ -950,7 +1031,13 @@ function taskComplete(repo, options) {
|
|
|
950
1031
|
const contract = compileTaskContract(repo, selection.change, task);
|
|
951
1032
|
const usableContract = contract.diagnostics.length === 0 ? contract : null;
|
|
952
1033
|
const changeVerify = changeVerifyChecks(selection.content, selection.tasks);
|
|
953
|
-
const checks = completionChecks(
|
|
1034
|
+
const checks = completionChecks(
|
|
1035
|
+
repo,
|
|
1036
|
+
task,
|
|
1037
|
+
usableContract,
|
|
1038
|
+
changeVerify,
|
|
1039
|
+
selection.change
|
|
1040
|
+
);
|
|
954
1041
|
checks.problems.push(...contract.diagnostics);
|
|
955
1042
|
const missingAnchor = missingAnchorProblem(selection, task);
|
|
956
1043
|
if (missingAnchor) {
|
|
@@ -1125,7 +1212,7 @@ function changeVerifyProblems(content, tasks) {
|
|
|
1125
1212
|
// the author was not already holding in mind, so a list of remembered files
|
|
1126
1213
|
// reproduces the failure; a searchable phrase is what turns the declaration
|
|
1127
1214
|
// into a grep. What the phrase says is the agent's judgment, not the gate's.
|
|
1128
|
-
function invalidationProblems(repo, content, tasks) {
|
|
1215
|
+
function invalidationProblems(repo, content, tasks, change) {
|
|
1129
1216
|
const heading = content.search(/^## Invalidates\s*$/m);
|
|
1130
1217
|
if (heading < 0) {
|
|
1131
1218
|
return [
|
|
@@ -1159,7 +1246,15 @@ function invalidationProblems(repo, content, tasks) {
|
|
|
1159
1246
|
const problems = [];
|
|
1160
1247
|
for (const entry of entries) {
|
|
1161
1248
|
const [, id, body] = entry;
|
|
1162
|
-
|
|
1249
|
+
// The quotation is read across the entry, not one line of it. An entry
|
|
1250
|
+
// carries a quotation, a location, and a closure, and wraps as often as it
|
|
1251
|
+
// needs to — 42 of this repository's 194 archived entries span more than
|
|
1252
|
+
// one line. Requiring the quotation to fit on one refused entries that had
|
|
1253
|
+
// named exactly what was asked for, and offered no repair but reflowing
|
|
1254
|
+
// the text (issue #108). The bound is the entry: `entries` above splits on
|
|
1255
|
+
// the next `I<n>`, so a quotation cannot reach past its own. `Findings` in
|
|
1256
|
+
// this same file is already read as wrapping.
|
|
1257
|
+
if (!/"[^"]{3,}"/.test(body)) {
|
|
1163
1258
|
problems.push(
|
|
1164
1259
|
problem(
|
|
1165
1260
|
"invalidation-phrase",
|
|
@@ -1173,7 +1268,7 @@ function invalidationProblems(repo, content, tasks) {
|
|
|
1173
1268
|
const updated = body.match(/Updated by:\s*([0-9.,\s-]+)/i);
|
|
1174
1269
|
const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
|
|
1175
1270
|
const verdict = declaredOwner
|
|
1176
|
-
? durableOwnerVerdict(repo, declaredOwner[1])
|
|
1271
|
+
? durableOwnerVerdict(repo, declaredOwner[1], change)
|
|
1177
1272
|
: { ok: false, reason: "absent" };
|
|
1178
1273
|
const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
|
|
1179
1274
|
if (!updated && !verdict.ok && !discarded) {
|
|
@@ -1185,6 +1280,13 @@ function invalidationProblems(repo, content, tasks) {
|
|
|
1185
1280
|
+ "file exists in this repository."
|
|
1186
1281
|
)
|
|
1187
1282
|
);
|
|
1283
|
+
} else if (verdict.reason === "transient") {
|
|
1284
|
+
problems.push(
|
|
1285
|
+
problem(
|
|
1286
|
+
"invalidation-owner-transient",
|
|
1287
|
+
`${id} names ${transientOwnerMessage(verdict.path)}`
|
|
1288
|
+
)
|
|
1289
|
+
);
|
|
1188
1290
|
} else if (verdict.reason === "handoff") {
|
|
1189
1291
|
problems.push(
|
|
1190
1292
|
problem(
|
|
@@ -1227,7 +1329,7 @@ function invalidationProblems(repo, content, tasks) {
|
|
|
1227
1329
|
return problems;
|
|
1228
1330
|
}
|
|
1229
1331
|
|
|
1230
|
-
function expectationProblems(repo, content, tasks) {
|
|
1332
|
+
function expectationProblems(repo, content, tasks, change) {
|
|
1231
1333
|
const heading = content.search(/^## Expectation Coverage\s*$/m);
|
|
1232
1334
|
if (heading < 0) {
|
|
1233
1335
|
return [
|
|
@@ -1263,7 +1365,7 @@ function expectationProblems(repo, content, tasks) {
|
|
|
1263
1365
|
const covered = body.match(/Covered by:\s*([0-9.,\s-]+)/i);
|
|
1264
1366
|
const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
|
|
1265
1367
|
const verdict = declaredOwner
|
|
1266
|
-
? durableOwnerVerdict(repo, declaredOwner[1])
|
|
1368
|
+
? durableOwnerVerdict(repo, declaredOwner[1], change)
|
|
1267
1369
|
: { ok: false, reason: "absent" };
|
|
1268
1370
|
const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
|
|
1269
1371
|
if (!covered && !verdict.ok && !discarded) {
|
|
@@ -1275,6 +1377,13 @@ function expectationProblems(repo, content, tasks) {
|
|
|
1275
1377
|
+ "file exists in this repository."
|
|
1276
1378
|
)
|
|
1277
1379
|
);
|
|
1380
|
+
} else if (verdict.reason === "transient") {
|
|
1381
|
+
problems.push(
|
|
1382
|
+
problem(
|
|
1383
|
+
"expectation-owner-transient",
|
|
1384
|
+
`${id} names ${transientOwnerMessage(verdict.path)}`
|
|
1385
|
+
)
|
|
1386
|
+
);
|
|
1278
1387
|
} else {
|
|
1279
1388
|
problems.push(
|
|
1280
1389
|
problem(
|
|
@@ -1378,7 +1487,8 @@ function changeClose(repo, options) {
|
|
|
1378
1487
|
repo,
|
|
1379
1488
|
task,
|
|
1380
1489
|
contract.diagnostics.length === 0 ? contract : null,
|
|
1381
|
-
changeVerify
|
|
1490
|
+
changeVerify,
|
|
1491
|
+
selection.change
|
|
1382
1492
|
);
|
|
1383
1493
|
problems.push(
|
|
1384
1494
|
...checks.problems.map((item) =>
|
|
@@ -1391,7 +1501,9 @@ function changeClose(repo, options) {
|
|
|
1391
1501
|
)
|
|
1392
1502
|
);
|
|
1393
1503
|
}
|
|
1394
|
-
problems.push(
|
|
1504
|
+
problems.push(
|
|
1505
|
+
...expectationProblems(repo, selection.content, selection.tasks, selection.change)
|
|
1506
|
+
);
|
|
1395
1507
|
problems.push(...changeVerifyProblems(selection.content, selection.tasks));
|
|
1396
1508
|
|
|
1397
1509
|
const changePath = path.dirname(selection.tasksPath);
|
|
@@ -687,9 +687,16 @@ function criticalAuthority(repo, change, reference) {
|
|
|
687
687
|
};
|
|
688
688
|
}
|
|
689
689
|
const content = fs.readFileSync(designPath, "utf8");
|
|
690
|
+
// Accepted line shapes: an optional CommonMark list bullet, the identifier
|
|
691
|
+
// bare or wrapped in balanced `**`, then the dash and statement. Authors
|
|
692
|
+
// overwhelmingly write the bulleted and bold shapes (issue #49).
|
|
690
693
|
const matches = [
|
|
691
694
|
...content.matchAll(
|
|
692
|
-
new RegExp(
|
|
695
|
+
new RegExp(
|
|
696
|
+
`^\\s*(?:[-*+]\\s+)?(?:\\*\\*${reference}\\*\\*|${reference})`
|
|
697
|
+
+ `\\s*[—-]\\s*(.+?)\\s*$`,
|
|
698
|
+
"gmi"
|
|
699
|
+
)
|
|
693
700
|
),
|
|
694
701
|
];
|
|
695
702
|
if (matches.length !== 1) {
|
|
@@ -707,9 +714,11 @@ function criticalAuthority(repo, change, reference) {
|
|
|
707
714
|
code: "unresolved-covers",
|
|
708
715
|
message:
|
|
709
716
|
`Unparsed Covers critical statement: ${reference}. It appears in `
|
|
710
|
-
+ "design.md but not in
|
|
711
|
-
+
|
|
712
|
-
+
|
|
717
|
+
+ "design.md but not in an accepted line shape — write it as a "
|
|
718
|
+
+ "line opening with the identifier and a dash, "
|
|
719
|
+
+ `\`${reference} — one-line statement\`, optionally as a list `
|
|
720
|
+
+ `bullet (\`- ${reference} — …\`) and/or with the identifier `
|
|
721
|
+
+ `bold (\`**${reference}** — …\`).`,
|
|
713
722
|
},
|
|
714
723
|
};
|
|
715
724
|
}
|
|
@@ -758,7 +767,17 @@ function resolveAuthority(repo, change, task) {
|
|
|
758
767
|
}
|
|
759
768
|
const entries = [...seen].sort();
|
|
760
769
|
for (const entry of entries) {
|
|
761
|
-
|
|
770
|
+
// A critical-statement reference may open its entry with a trailing
|
|
771
|
+
// annotation after a dash (`D2 — note`); the identifier resolves and the
|
|
772
|
+
// annotation stays annotation — design.md owns the statement text. The
|
|
773
|
+
// boundary after the identifier is whitespace or an em dash so that free
|
|
774
|
+
// text like `D2-compatible` does not become a reference.
|
|
775
|
+
const annotated = entry.match(/^([DFAQ]\d+)(?=\s|—)\s*[—-]\s*.+$/);
|
|
776
|
+
const critical = criticalAuthority(
|
|
777
|
+
repo,
|
|
778
|
+
change,
|
|
779
|
+
annotated ? annotated[1] : entry
|
|
780
|
+
);
|
|
762
781
|
if (critical) {
|
|
763
782
|
if (critical.diagnostic) diagnostics.push(critical.diagnostic);
|
|
764
783
|
if (critical.authority) authority.push(critical.authority);
|