@christang/keel 5.14.0 → 5.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/src/core/gates.js CHANGED
@@ -14,7 +14,7 @@ const {
14
14
  isPassingReviewStatus,
15
15
  parseTasks,
16
16
  } = require("./task-contract");
17
- const { startGuard } = require("./guard");
17
+ const { gitPaths, readManifest, startGuard } = require("./guard");
18
18
 
19
19
  const GATE_STAGES = new Set(["task-start", "task-complete", "change-close"]);
20
20
 
@@ -372,6 +372,35 @@ const DURABLE_OWNER_FORMS =
372
372
  + "repository's own ledger; `keel/HANDOFF.md` is a pointer override rather "
373
373
  + "than an owner";
374
374
 
375
+ // Trailing punctuation a declared path can abut in prose. ASCII sentence marks
376
+ // and their CJK counterparts both belong here: once the extractor stops
377
+ // assuming ASCII, its terminators cannot assume ASCII either. A Chinese
378
+ // sentence ends in `。`, which is not whitespace, so a non-whitespace run
379
+ // swallows it and the gate looks for a file that cannot exist.
380
+ const DECLARED_PATH_TRAILING = /[.,;:!?)\]}"'\u2019\u201d\u3002\uff0c\u3001\uff1b\uff1a\uff01\uff1f\uff09\u3011\u300b\u300d\u300f]+$/;
381
+
382
+ // A declared path is a run of non-whitespace holding a separator. What ends a
383
+ // path is whitespace; what a path is *made of* is the filesystem's business,
384
+ // and answering the first question with the second is what refused
385
+ // `notes/note-006-转岗最难的不是流程/note.md` by reporting that
386
+ // `notes/note-006-` does not exist — a path nobody wrote (issue #60). It is the
387
+ // same class as #40 on the worktree-reading side, which survived because that
388
+ // fix repaired one reader rather than how paths are extracted; this is the one
389
+ // extractor every gate reader of a declared path now uses.
390
+ //
391
+ // The backtick form wins when present. It is the only way to write a path
392
+ // containing whitespace, and `touchEntries` already strips backticks from a
393
+ // Touch entry, so one authorship stops being spelled two ways depending on
394
+ // which reader will read it.
395
+ function declaredPath(value) {
396
+ const text = String(value || "");
397
+ const quoted = text.match(/`([^`\n]*\/[^`\n]*)`/);
398
+ if (quoted) return quoted[1].trim() || null;
399
+ const bare = text.match(/[^\s`]+\/[^\s`]+/);
400
+ if (!bare) return null;
401
+ return bare[0].replace(DECLARED_PATH_TRAILING, "") || null;
402
+ }
403
+
375
404
  // Classify a declared `Durable owner:` value. A gate runs without network, so a
376
405
  // URL is accepted on shape alone; a path is the one form it can actually check,
377
406
  // and checking it is stricter than the prefix whitelist this replaced.
@@ -380,10 +409,10 @@ function durableOwnerVerdict(repo, value) {
380
409
  if (!owner) return { ok: false, reason: "unrecognized" };
381
410
  if (/keel\/HANDOFF\.md/i.test(owner)) return { ok: false, reason: "handoff" };
382
411
  if (TRACKER_REFERENCE.test(owner)) return { ok: true };
383
- const candidate = owner.match(/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+/);
412
+ const candidate = declaredPath(owner);
384
413
  if (!candidate) return { ok: false, reason: "unrecognized" };
385
- if (fs.existsSync(path.join(repo, candidate[0]))) return { ok: true };
386
- return { ok: false, reason: "missing", path: candidate[0] };
414
+ if (fs.existsSync(path.join(repo, candidate))) return { ok: true };
415
+ return { ok: false, reason: "missing", path: candidate };
387
416
  }
388
417
 
389
418
  // A finding has three dispositions and the gate recognized two. One found and
@@ -423,10 +452,10 @@ function resolutionEvidenceVerdict(repo, value, commands) {
423
452
  if (commands.includes(cited[0])) return { ok: true };
424
453
  return { ok: false, reason: "unknown-check", label: cited[0] };
425
454
  }
426
- const candidate = evidence.match(/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+/);
455
+ const candidate = declaredPath(evidence);
427
456
  if (!candidate) return { ok: false, reason: "unrecognized" };
428
- if (fs.existsSync(path.join(repo, candidate[0]))) return { ok: true };
429
- return { ok: false, reason: "missing", path: candidate[0] };
457
+ if (fs.existsSync(path.join(repo, candidate))) return { ok: true };
458
+ return { ok: false, reason: "missing", path: candidate };
430
459
  }
431
460
 
432
461
  function resolutionEvidenceMessage(verdict) {
@@ -466,13 +495,12 @@ function findingOwnerIsDurable(repo, findings) {
466
495
  /\b(openspec\/changes\/[A-Za-z0-9][A-Za-z0-9._-]*\/(?:proposal|design|tasks)\.md)(?:#\d+(?:\.\d+)*)?/i
467
496
  );
468
497
  if (artifact && fs.existsSync(path.join(repo, artifact[1]))) return true;
469
- return /\bkeel\/archive\/[A-Za-z0-9._/-]+/i.test(findings)
470
- && fs.existsSync(
471
- path.join(
472
- repo,
473
- findings.match(/\bkeel\/archive\/[A-Za-z0-9._/-]+/i)[0]
474
- )
475
- );
498
+ // Same extractor, scoped to the archive prefix: the segment after
499
+ // `keel/archive/` is a path like any other and was equally ASCII-bound.
500
+ const archive = findings.match(/keel\/archive\/[^\s`]*/i);
501
+ if (!archive) return false;
502
+ const archivePath = declaredPath(archive[0]);
503
+ return Boolean(archivePath) && fs.existsSync(path.join(repo, archivePath));
476
504
  }
477
505
 
478
506
  // Read in `-z` form, because every other form escapes. Git octal-escapes any
@@ -483,33 +511,21 @@ function findingOwnerIsDurable(repo, findings) {
483
511
  // decoder — and it is a flag rather than a repository setting, so the answer
484
512
  // does not depend on how the repository happens to be configured.
485
513
  //
486
- // Nothing rewrites backslashes here any more. Git emits forward slashes on
487
- // every platform, so the rewrite normalized a separator that never arrives
488
- // while turning `\346` into `/346`, which is how a path declared on the first
489
- // line of Touch was reported as outside Touch (issue #40).
490
- function gitPaths(repo) {
491
- const status = spawnSync(
492
- "git",
493
- ["status", "--porcelain=v1", "-z", "--untracked-files=all"],
494
- { cwd: repo, encoding: "utf8" }
495
- );
496
- if (status.error || status.status !== 0) return [];
497
- // Each record is `XY <path>`, NUL-terminated. A rename or copy is followed
498
- // by a second bare field holding its other endpoint — the new path first in
499
- // `-z`, the reverse of the ` -> ` line format. The order is immaterial:
500
- // both endpoints are attributed, so a rename whose paths are both in Touch
501
- // is not a false outside-Touch failure.
502
- const fields = status.stdout.split("\0").filter(Boolean);
503
- const paths = [];
504
- for (let index = 0; index < fields.length; index += 1) {
505
- const record = fields[index];
506
- paths.push(record.slice(3));
507
- if (record[0] === "R" || record[0] === "C") {
508
- index += 1;
509
- if (index < fields.length) paths.push(fields[index]);
510
- }
511
- }
512
- return paths;
514
+ // `gitPaths` moved to guard.js, which owns the worktree reading now that the
515
+ // task-start record and this comparison must use the same one.
516
+
517
+ // The dirty set recorded when this task was authorized, or null when nobody
518
+ // recorded one. Null and empty are different answers: an empty list says
519
+ // nothing was dirty at task start, and null says no record exists, which is
520
+ // what a manifest written before this field, a cleared guard, or a
521
+ // `--no-guard` start all produce. Reading null as empty would attribute the
522
+ // whole worktree to the task and fail every completion in a dirty repository.
523
+ function recordedBaseline(repo, change, task) {
524
+ const loaded = readManifest(repo);
525
+ if (loaded.state !== "ok") return null;
526
+ const manifest = loaded.manifest;
527
+ if (manifest.change !== change || manifest.task !== task) return null;
528
+ return Array.isArray(manifest.startedDirty) ? manifest.startedDirty : null;
513
529
  }
514
530
 
515
531
  function touchEntries(task, contract = null) {
@@ -570,7 +586,15 @@ function scopeEvidence(
570
586
  tasks = null
571
587
  ) {
572
588
  const dirtyPaths = gitPaths(repo);
573
- if (!base) {
589
+ // An explicit base wins. It asks a broader question than the record does —
590
+ // everything since that commit, not only since this task started — and
591
+ // substituting the narrower answer would make `--base` mean something other
592
+ // than what it says.
593
+ const baseline = base ? null : recordedBaseline(repo, change, task.id);
594
+ if (!base && !baseline) {
595
+ // No base and no record: the original conservatism, and the reason for it
596
+ // is unchanged. Git alone cannot say which task of a half-finished change
597
+ // wrote a given path, so the dirty state stays semantic review evidence.
574
598
  return {
575
599
  problems: [],
576
600
  warnings:
@@ -583,6 +607,27 @@ function scopeEvidence(
583
607
  };
584
608
  }
585
609
 
610
+ if (!base) {
611
+ // Dirty now and not dirty when the task started. This answers "did this
612
+ // task write it", which is the question the boundary actually asks; it
613
+ // does not answer "which task wrote it", which is why the completed-
614
+ // sibling exclusion below still applies and still reports itself.
615
+ //
616
+ // A path already dirty at task start is subtracted even if the task also
617
+ // modified it. That is the price of a baseline that is not a commit, and
618
+ // it buys the far larger class this exists to avoid: failing every
619
+ // completion in a worktree that was dirty before the task began.
620
+ const startedDirty = new Set(baseline);
621
+ return attributeChanged(
622
+ repo,
623
+ task,
624
+ dirtyPaths.filter((item) => !startedDirty.has(item)),
625
+ contract,
626
+ change,
627
+ tasks
628
+ );
629
+ }
630
+
586
631
  const verified = spawnSync(
587
632
  "git",
588
633
  ["rev-parse", "--verify", `${base}^{commit}`],
@@ -601,10 +646,23 @@ function scopeEvidence(
601
646
  if (diff.error || diff.status !== 0) {
602
647
  throw new GateInputError(`could not compare Git base: ${base}`);
603
648
  }
604
- const changed = new Set([
605
- ...diff.stdout.split("\0").filter(Boolean),
606
- ...dirtyPaths,
607
- ]);
649
+ return attributeChanged(
650
+ repo,
651
+ task,
652
+ [...diff.stdout.split("\0").filter(Boolean), ...dirtyPaths],
653
+ contract,
654
+ change,
655
+ tasks
656
+ );
657
+ }
658
+
659
+ // The one place a candidate path becomes a problem, whichever comparison
660
+ // produced it. Both callers reach it: the recorded-baseline path and the
661
+ // explicit-base path differ only in how they decide which paths are
662
+ // candidates, and a second copy of this would be a second definition of what
663
+ // Touch means.
664
+ function attributeChanged(repo, task, changedList, contract, change, tasks) {
665
+ const changed = new Set(changedList);
608
666
  const touch = touchEntries(task, contract);
609
667
  // The disposable guard manifest is the one artifact the gate contract itself
610
668
  // permits a gate to write, and the selected change's own authoring artifacts
@@ -648,7 +706,13 @@ function completionChecks(repo, task, contract = null) {
648
706
  const commands = contract
649
707
  ? contract.capsule.verification.commands.map((item) => item.label)
650
708
  : commandLabels(task);
651
- if (commands.length === 0) {
709
+ // With no contract, the labels came from the expanded v3 `Commands` field,
710
+ // which a compact task never declares — so their absence is a fact about the
711
+ // fallback, not about the task. The compiler's own diagnostics are already in
712
+ // `problems` (the caller pushes them unconditionally, and an unusable
713
+ // contract has at least one), so this cannot turn a refusal into a pass. The
714
+ // per-label evidence checks below stay: a genuine v3 task yields real labels.
715
+ if (contract && commands.length === 0) {
652
716
  problems.push(problem("missing-commands", "Commands must define at least one M<n>."));
653
717
  }
654
718
  for (const label of commands) {
package/src/core/guard.js CHANGED
@@ -9,8 +9,44 @@
9
9
  const crypto = require("crypto");
10
10
  const fs = require("fs");
11
11
  const path = require("path");
12
+ const { spawnSync } = require("child_process");
12
13
  const { loadTaskContract } = require("./task-contract");
13
14
 
15
+ // Nothing rewrites backslashes here. Git emits forward slashes on every
16
+ // platform, so the rewrite normalized a separator that never arrives while
17
+ // turning `\346` into `/346`, which is how a path declared on the first line
18
+ // of Touch was reported as outside Touch (issue #40).
19
+ //
20
+ // This lives here rather than in gates.js because both the task-start record
21
+ // and the completion comparison read it, and gates.js already requires this
22
+ // module. One implementation is the point: a baseline and a comparison that
23
+ // disagreed about what "dirty" means, or about how a rename is represented,
24
+ // would attribute a path nobody wrote.
25
+ function gitPaths(repo) {
26
+ const status = spawnSync(
27
+ "git",
28
+ ["status", "--porcelain=v1", "-z", "--untracked-files=all"],
29
+ { cwd: repo, encoding: "utf8" }
30
+ );
31
+ if (status.error || status.status !== 0) return [];
32
+ // Each record is `XY <path>`, NUL-terminated. A rename or copy is followed
33
+ // by a second bare field holding its other endpoint — the new path first in
34
+ // `-z`, the reverse of the ` -> ` line format. The order is immaterial:
35
+ // both endpoints are attributed, so a rename whose paths are both in Touch
36
+ // is not a false outside-Touch failure.
37
+ const fields = status.stdout.split("\0").filter(Boolean);
38
+ const paths = [];
39
+ for (let index = 0; index < fields.length; index += 1) {
40
+ const record = fields[index];
41
+ paths.push(record.slice(3));
42
+ if (record[0] === "R" || record[0] === "C") {
43
+ index += 1;
44
+ if (index < fields.length) paths.push(fields[index]);
45
+ }
46
+ }
47
+ return paths;
48
+ }
49
+
14
50
  const MANIFEST_SCHEMA = "keel-write-guard/v1";
15
51
 
16
52
  class GuardInputError extends Error {}
@@ -121,6 +157,18 @@ function readManifest(repo) {
121
157
  ) {
122
158
  shapeErrors.push("authority must list hashed source files");
123
159
  }
160
+ // Optional on purpose. A manifest written before this field existed, and one
161
+ // written by a Keel that omits it, are both valid; what they are not is
162
+ // evidence that nothing was dirty. The consumer distinguishes absent from
163
+ // empty, so an empty list means "nothing was dirty" and an absent one means
164
+ // "nobody looked".
165
+ if (
166
+ manifest.startedDirty !== undefined
167
+ && (!Array.isArray(manifest.startedDirty)
168
+ || manifest.startedDirty.some((item) => typeof item !== "string"))
169
+ ) {
170
+ shapeErrors.push("startedDirty must be a string list when present");
171
+ }
124
172
  if (shapeErrors.length > 0) {
125
173
  return {
126
174
  state: "invalid",
@@ -182,6 +230,9 @@ function startGuard(repo, options) {
182
230
  }
183
231
 
184
232
  const paths = authorityPaths(repo, options.change, loaded.contract);
233
+ // Read before the manifest is written, so the manifest is never in its own
234
+ // record and cannot be attributed to the task it authorizes.
235
+ const startedDirty = gitPaths(repo);
185
236
  const manifest = {
186
237
  schema: MANIFEST_SCHEMA,
187
238
  change: options.change,
@@ -189,6 +240,7 @@ function startGuard(repo, options) {
189
240
  fingerprint: loaded.contract.fingerprint,
190
241
  touch: loaded.contract.capsule.touch,
191
242
  authority: hashAuthority(repo, paths),
243
+ startedDirty,
192
244
  };
193
245
  fs.mkdirSync(path.join(repo, "keel"), { recursive: true });
194
246
  fs.writeFileSync(
@@ -302,6 +354,7 @@ module.exports = {
302
354
  GuardInputError,
303
355
  MANIFEST_SCHEMA,
304
356
  clearGuard,
357
+ gitPaths,
305
358
  guardStatus,
306
359
  readManifest,
307
360
  renderGuard,
@@ -416,10 +416,24 @@ function requiredFieldProblems(task) {
416
416
  function missingFieldProblems(task, names) {
417
417
  return names
418
418
  .filter((name) => !isConcrete(field(task, name)))
419
- .map((name) => ({
420
- code: "missing-field",
421
- message: `${name} must be concrete.`,
422
- }));
419
+ .map((name) => {
420
+ // Name the matched slot, the way the check and Verify diagnostics already
421
+ // do. The unqualified wording stated only the verdict, so an author whose
422
+ // field held a token in ordinary prose had nothing to search for — and
423
+ // the first problem they saw named a field of the other schema instead.
424
+ // The code is unchanged: the verdict is the same either way, and a new
425
+ // one would hide this case from every consumer keying on `missing-field`.
426
+ const token = unfilledToken(field(task, name));
427
+ return {
428
+ code: "missing-field",
429
+ message: token
430
+ ? `${name} carries the unfilled slot \`${token}\`, so it is not `
431
+ + "concrete. Replace that slot with the value it stands for, or "
432
+ + "fence it in inline code when it is literal text — a numeric "
433
+ + "range or test output — rather than a slot left to fill."
434
+ : `${name} must be concrete.`,
435
+ };
436
+ });
423
437
  }
424
438
 
425
439
  function canonical(value) {