@afokapu/atdd-flow 0.3.57 → 0.3.58

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/core.ts +50 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.57",
3
+ "version": "0.3.58",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/core.ts CHANGED
@@ -1,8 +1,9 @@
1
- import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
- import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
5
+ import { isDeepStrictEqual } from "node:util";
6
+ import { Document, Scalar, parse as parseYaml, stringify as stringifyYaml, visit } from "yaml";
6
7
 
7
8
  export type Role = { address: string; branch: string; agent?: string; purpose?: string; worktree?: string; base?: string };
8
9
  export type Group = { role?: string; members?: string[] };
@@ -133,9 +134,48 @@ export const required = <T>(value: T | undefined, label: string) => {
133
134
  if (value === undefined || value === "") throw new Error(`Missing ${label}.`);
134
135
  return value;
135
136
  };
137
+ const printOptions = { collectionStyle: "flow", flowCollectionPadding: false } as const;
138
+
139
+ // What a faithful YAML round trip must return: JSON semantics, but never silently lossy.
140
+ function expectedRoundTrip(value: unknown) {
141
+ return JSON.parse(JSON.stringify(value, (_key, entry) => {
142
+ if (typeof entry === "function" || typeof entry === "symbol" || typeof entry === "bigint") throw new Error("Value is not representable in YAML.");
143
+ if (typeof entry === "number" && !Number.isFinite(entry)) throw new Error("Value is not representable in YAML.");
144
+ return entry;
145
+ }));
146
+ }
147
+
148
+ // Tier 2 keeps multi-line text readable as block literals; tier 3 quotes every string.
149
+ const printTiers: Array<(expected: unknown) => string> = [
150
+ (expected) => stringifyYaml(expected, printOptions),
151
+ (expected) => {
152
+ const document = new Document(expected, { ...printOptions, lineWidth: 0 });
153
+ visit(document, { Scalar: (_key, node) => { if (typeof node.value === "string" && node.value.includes("\n")) node.type = Scalar.BLOCK_LITERAL; } });
154
+ return document.toString();
155
+ },
156
+ (expected) => stringifyYaml(expected, { ...printOptions, lineWidth: 0, defaultStringType: "QUOTE_DOUBLE", defaultKeyType: "PLAIN" }),
157
+ ];
158
+
159
+ // A parse error and a value mismatch are equally unsafe to publish, so both count as "does not round-trip".
160
+ // Strict deep equality also catches silent coercions such as the string "null" becoming null.
161
+ function roundTrips(text: string, expected: unknown) {
162
+ try { return isDeepStrictEqual(parseYaml(text), expected); }
163
+ catch { return false; }
164
+ }
165
+
166
+ /** Serializes and proves the text parses back to the same value; the first tier that does wins, otherwise fail closed. */
167
+ function printVerified(value: unknown) {
168
+ const expected = expectedRoundTrip(value);
169
+ for (const tier of printTiers) {
170
+ const text = tier(expected);
171
+ if (roundTrips(text, expected)) return text;
172
+ }
173
+ throw new Error("Refusing to write YAML that does not parse back to the original value.");
174
+ }
175
+
136
176
  export const yaml = {
137
177
  parse: <T>(text: string) => parseYaml(text) as T,
138
- print: (value: unknown) => stringifyYaml(value, { collectionStyle: "flow", flowCollectionPadding: false }),
178
+ print: (value: unknown) => printVerified(value),
139
179
  };
140
180
 
141
181
  export function addressParts(address: string) {
@@ -176,7 +216,13 @@ export const paths = (root: string) => ({
176
216
  export async function atomicYaml(file: string, value: unknown) {
177
217
  await mkdir(dirname(file), { recursive: true });
178
218
  const temp = join(dirname(file), `.${basename(file)}.${randomUUID()}.tmp`);
179
- await writeFile(temp, yaml.print(value), "utf8");
219
+ const text = yaml.print(value);
220
+ await writeFile(temp, text, "utf8");
221
+ // yaml.print already proved this text round-trips; compare the bytes on disk to it before the rename publishes them.
222
+ if ((await readFile(temp, "utf8")) !== text) {
223
+ await rm(temp, { force: true });
224
+ throw new Error(`Refusing to publish ${file}: written YAML does not round-trip.`);
225
+ }
180
226
  await rename(temp, file);
181
227
  }
182
228