@praxisflux/gates 0.6.1 → 0.6.3

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.
@@ -110,15 +110,18 @@ function openTagsWithClass(html, classToken) {
110
110
  return out;
111
111
  }
112
112
 
113
- /** Each .translation-block open tag, its attrs, and its chunk extent (to the
114
- * next block or end of input blocks never nest, and the tracked classes
115
- * only occur inside blocks, so chunk counting is exact). */
113
+ /** Each .translation-block element with its REAL extent from its open tag
114
+ * to its own nesting-aware closing </div> (via findElements), not to the
115
+ * next block's open tag. Content between blocks therefore belongs to no
116
+ * chunk; the orphan scan in checkTranslationBlocks owns that territory.
117
+ * A block whose close tag is missing is not returned here (findElements
118
+ * can't bound it) — checkTranslationBlocks flags the unmatched open. */
116
119
  function findBlocks(html) {
117
- const opens = [];
118
- const re = /<div([^>]*)>/g;
119
- let m;
120
- while ((m = re.exec(html))) if (hasClassToken(m[1], "translation-block")) opens.push({ start: m.index, attrs: m[1] });
121
- return opens.map((o, i) => ({ ...o, end: i + 1 < opens.length ? opens[i + 1].start : html.length, n: i + 1 }));
120
+ return findElements(html, "div", "translation-block").map((el, i) => ({
121
+ ...el,
122
+ attrs: (/^<div([^>]*)>/.exec(html.slice(el.start, el.innerStart)) || [, ""])[1],
123
+ n: i + 1,
124
+ }));
122
125
  }
123
126
 
124
127
  /* ── bracket balance over code text ───────────────────────────── */
@@ -177,6 +180,28 @@ export function checkTranslationBlocks(html, source = "html") {
177
180
  const fails = [];
178
181
  const details = [];
179
182
  const blocks = findBlocks(html);
183
+
184
+ // A translation-block open tag findElements couldn't close never reaches
185
+ // `blocks` — without this check it (and its contents) would silently skip
186
+ // validation entirely.
187
+ const bounded = new Set(blocks.map((b) => b.start));
188
+ const openRe = /<div([^>]*)>/g;
189
+ let om;
190
+ while ((om = openRe.exec(html)))
191
+ if (hasClassToken(om[1], "translation-block") && !bounded.has(om.index))
192
+ fails.push(`${source}: .translation-block opened on line ${lineOf(html, om.index)} has no matching closing </div> — nothing inside it can be validated. Close the block.`);
193
+
194
+ // Tracked content outside every block extent is unattributable: the old
195
+ // next-open-tag chunks blamed it on a neighboring block (or missed it
196
+ // before the first block); now it fails by name.
197
+ const inBlock = (i) => blocks.some((b) => i >= b.start && i < b.end);
198
+ for (const [cls, label] of [["translation-code", ".translation-code panel"], ["code-line", ".code-line span"], ["tl", ".tl note"]]) {
199
+ for (const t of openTagsWithClass(html, cls)) {
200
+ if (!inBlock(t.start))
201
+ fails.push(`${source}: orphan ${label} on line ${lineOf(html, t.start)} sits outside every .translation-block — the validator cannot attribute it and the renderer will misplace it. Wrap it in a .translation-block or delete it.`);
202
+ }
203
+ }
204
+
180
205
  for (const b of blocks) {
181
206
  const id = `${source}: translation block ${b.n} (line ${lineOf(html, b.start)})`;
182
207
  if (/data-validate\s*=\s*["']off["']/.test(b.attrs)) { details.push({ ...b, skipped: true }); continue; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@praxisflux/gates",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "praxisflux gate checks as a zero-dependency CLI (spec-bridge, wiki-freshness, course) — status can't exceed proven artifacts",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,8 +33,10 @@ const RANK = { "to do": 0, "in progress": 1, done: 2 };
33
33
  const DERIVED_RANK = { [STATUS.TODO]: 0, [STATUS.IN_PROGRESS]: 1, [STATUS.DONE_ELIGIBLE]: 2 };
34
34
 
35
35
  /**
36
- * Parse one Backlog task file. Returns { id, status, specDir } for a linked task,
37
- * null for anything else (no marker, unreadable, or not a task file).
36
+ * Parse one Backlog task file. Returns { id, status, specDir, acs } for a linked task,
37
+ * null for anything else (no marker, unreadable, or not a task file). `acs` is the task's
38
+ * acceptance criteria as [{ index, checked, text }] read from the AC:BEGIN/END block —
39
+ * still read-only; the plan command needs them to compute reconciling edits.
38
40
  */
39
41
  export function parseLinkedTask(raw) {
40
42
  const text = String(raw ?? "");
@@ -46,7 +48,12 @@ export function parseLinkedTask(raw) {
46
48
  const id = field("id");
47
49
  const status = field("status");
48
50
  if (!id) return null;
49
- return { id, status, specDir: marker[1] };
51
+ const acs = [];
52
+ const block = text.match(/<!-- AC:BEGIN -->([\s\S]*?)<!-- AC:END -->/);
53
+ if (block)
54
+ for (const m of block[1].matchAll(/^- \[( |x|X)\] #(\d+)\s+(.*\S)\s*$/gm))
55
+ acs.push({ index: +m[2], checked: m[1] !== " ", text: m[3] });
56
+ return { id, status, specDir: marker[1], acs };
50
57
  }
51
58
 
52
59
  /** Scan <root>/backlog/tasks/*.md for linked tasks. Unreadable files are skipped. */
@@ -117,6 +124,90 @@ export function checkBridge(root) {
117
124
  return { links, problems, warnings };
118
125
  }
119
126
 
127
+ /* ── plan: the exact backlog edits that reconcile the board ────────────── */
128
+
129
+ const PHASE_PREFIX = "Spec phase: ";
130
+
131
+ /** Single-quote a string for verbatim shell use. */
132
+ const sq = (s) => `'${String(s).replace(/'/g, "'\\''")}'`;
133
+
134
+ /**
135
+ * Pure planner for ONE linked task: the ordered `backlog task edit` commands that reconcile
136
+ * it to its derived state. Command order matters and is: status move → stale phase-AC
137
+ * removals (highest index first, so earlier indexes stay valid) → phase-AC additions →
138
+ * check/uncheck at post-edit indexes → one progress note (only when something changed).
139
+ * ACs that don't start with "Spec phase: " are human-authored and are never touched.
140
+ */
141
+ export function planLinkedTask(task, derived) {
142
+ const cmds = [];
143
+ const edit = (args) => cmds.push(`backlog task edit ${task.id} ${args}`);
144
+
145
+ // Status — Done-eligible is the only path to Done and carries the derived final summary.
146
+ const target = derived.status === STATUS.DONE_ELIGIBLE ? "Done" : derived.status;
147
+ const statusChanged = String(task.status).toLowerCase() !== target.toLowerCase();
148
+ if (statusChanged) {
149
+ if (target === "Done")
150
+ edit(`-s ${sq("Done")} --final-summary ${sq(`All spec tasks complete (${derived.progressNote}). Derived Done by spec-bridge sync.`)}`);
151
+ else edit(`-s ${sq(target)}`);
152
+ }
153
+
154
+ // Phase-AC reconciliation. The bridge owns exactly the "Spec phase: " ACs.
155
+ const phaseByName = new Map((derived.phases || []).map((p) => [p.name, p]));
156
+ const seen = new Set();
157
+ const removed = new Set();
158
+ for (const a of task.acs || []) {
159
+ if (!a.text.startsWith(PHASE_PREFIX)) continue;
160
+ const name = a.text.slice(PHASE_PREFIX.length);
161
+ if (!phaseByName.has(name) || seen.has(name)) removed.add(a.index); // stale or duplicate
162
+ else seen.add(name);
163
+ }
164
+ for (const i of [...removed].sort((a, z) => z - a)) edit(`--remove-ac ${i}`);
165
+
166
+ const additions = (derived.phases || []).filter((p) => !seen.has(p.name));
167
+ for (const p of additions) edit(`--ac ${sq(PHASE_PREFIX + p.name)}`);
168
+
169
+ // Post-edit indexes: survivors keep their relative order and renumber from 1; additions
170
+ // append after them, unchecked. Check/uncheck against what each phase actually proves.
171
+ const survivors = (task.acs || []).filter((a) => !removed.has(a.index));
172
+ const finalList = [
173
+ ...survivors.map((a) => ({ text: a.text, checked: a.checked })),
174
+ ...additions.map((p) => ({ text: PHASE_PREFIX + p.name, checked: false })),
175
+ ];
176
+ finalList.forEach((item, i) => {
177
+ if (!item.text.startsWith(PHASE_PREFIX)) return; // human-authored: never touched
178
+ const p = phaseByName.get(item.text.slice(PHASE_PREFIX.length));
179
+ if (!p) return;
180
+ const want = p.total > 0 && p.done === p.total;
181
+ if (want && !item.checked) edit(`--check-ac ${i + 1}`);
182
+ else if (!want && item.checked) edit(`--uncheck-ac ${i + 1}`);
183
+ });
184
+
185
+ // One note, only when this sync changed something — no churn in the task history.
186
+ if (cmds.length) {
187
+ const suffix = statusChanged ? ` — status ${task.status} → ${target}` : "";
188
+ edit(`--append-notes ${sq(`spec-bridge sync: ${derived.progressNote}${suffix}`)}`);
189
+ }
190
+ return cmds;
191
+ }
192
+
193
+ /**
194
+ * Plan every linked task under <root>, in queue order. Returns:
195
+ * commands — ordered `backlog task edit` lines; empty on a reconciled board (no-op)
196
+ * skipped — [{ id, status }] for verdict-unknown tasks (custom status: don't guess)
197
+ * Read-only like everything in gates/: plan PRINTS edits, it never executes them.
198
+ */
199
+ export function planBridge(root) {
200
+ const commands = [];
201
+ const skipped = [];
202
+ const requireAnalysis = loadBridgeConfig(root).strictDone === true;
203
+ for (const task of findLinkedTasks(root)) {
204
+ const derived = deriveSpecState(join(root, task.specDir), { requireAnalysis });
205
+ if (verdict(task.status, derived.status) === "unknown") { skipped.push({ id: task.id, status: task.status }); continue; }
206
+ commands.push(...planLinkedTask(task, derived));
207
+ }
208
+ return { commands, skipped };
209
+ }
210
+
120
211
  /**
121
212
  * The Stop-hook gate, in gate-runner shape. Roots are directories holding a backlog/ dir;
122
213
  * a root with no linked tasks yields no problems, so the gate is a natural no-op outside
@@ -4,14 +4,17 @@
4
4
  // node cli.mjs state <specDir> derived state for one spec dir, as JSON
5
5
  // node cli.mjs links <root> every linked task under <root> with derived state + verdict, as JSON
6
6
  // node cli.mjs check <root> human report; exit 1 if any task's status exceeds its artifacts
7
+ // node cli.mjs plan <root> the ordered `backlog task edit` commands that reconcile the
8
+ // board to the derived state (stdout; nothing on a reconciled
9
+ // board). Prints, NEVER executes — the sync skill runs them.
7
10
  import { resolve } from "node:path";
8
11
  import { deriveSpecState } from "../lib/spec-derive.mjs";
9
12
  import { findRootUpwards, hasChild } from "../lib/project-root.mjs";
10
- import { checkBridge, loadBridgeConfig } from "./bridge.mjs";
13
+ import { checkBridge, loadBridgeConfig, planBridge } from "./bridge.mjs";
11
14
 
12
15
  const [cmd, target] = process.argv.slice(2);
13
16
  if (!cmd || !target) {
14
- console.error("usage: cli.mjs state <specDir> | links <root> | check <root>");
17
+ console.error("usage: cli.mjs state <specDir> | links <root> | check <root> | plan <root>");
15
18
  process.exit(2);
16
19
  }
17
20
 
@@ -31,6 +34,11 @@ if (cmd === "state") {
31
34
  process.exit(1);
32
35
  }
33
36
  console.log(`spec-bridge ok: ${links.length} linked task(s), none exceed their artifacts`);
37
+ } else if (cmd === "plan") {
38
+ const { commands, skipped } = planBridge(target);
39
+ for (const s of skipped)
40
+ console.error(`# ${s.id}: status "${s.status}" is outside To Do/In Progress/Done — not planned; resolve by hand`);
41
+ for (const c of commands) console.log(c);
34
42
  } else {
35
43
  console.error(`unknown command: ${cmd}`);
36
44
  process.exit(2);