Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion openspec/changes/add-markdoc-document-creation/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,12 +288,29 @@ only.

```text
docx-markdoc create <document.mdoc> <output-dir>
[--style-profile profile.json] [--no-pdf] [--require-pdf] [--replace]
[--style-profile profile.json] [--no-pdf] [--require-pdf]
[--replace | --dangerously-overwrite-edited-docx]
```

- The output directory may already exist.
- Each output path must be new unless `--replace` is given. `--replace` is
meant for rebuild loops in a matter's `outbound/draft`.
- **Edited outputs (#1186).** A person may edit `<stem>.docx` in Word after a
build. `--replace` rebuilds over an existing DOCX only when its SHA-256
equals the `docxSha256` of a passing `<stem>.verification.json`; otherwise
it fails with `CREATION_OUTPUT_EDITED` and changes nothing. A missing,
unreadable or failed certificate, or one without a fingerprint, cannot show
the DOCX is unedited, so it is refused too. The check runs before the build
and again at publication, on the bytes actually moved aside, so an edit made
during the build is caught and rolled back.
`--dangerously-overwrite-edited-docx` implies `--replace` and accepts such
a DOCX. The displaced bytes, exactly as moved aside, are kept in
`.<stem>.create-overwritten-<uuid>/<stem>.docx`, and the result and summary
name the path and SHA-256. If that move fails, the staging directory that
holds them is kept and named. Overwriting the certified DOCX keeps nothing.
These checks protect against edits that finish before publication moves
the DOCX aside. A program that still holds the file open and writes after
that move writes into the kept or backed-up copy, not the new output.
- Outputs are staged in a private directory inside the output directory and
published as one transaction after every check passes:
- each existing target, including a stale artifact this build does not
Expand Down
4 changes: 4 additions & 0 deletions openspec/changes/add-markdoc-document-creation/proposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ byte-deterministic, which the renderer's is not. What is missing is:
layer must match the source word for word; list labels, each page's
footer and page number, and repeated table headers are modelled from the
source, not excused by shape (#1185).
- `--replace` rebuilds only over the DOCX the last build certified (its
SHA-256 in `<stem>.verification.json`); an edited or uncertified DOCX is
refused unless `--dangerously-overwrite-edited-docx`, which keeps its exact
bytes in a named directory (#1186).
- A build that fails verification with a mismatch publishes nothing and
leaves `<stem>.failed-verification.json`, named in the error; the next
successful build removes it (#1185).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,11 @@ any verification fails.
- **THEN** it SHALL publish nothing and leave every existing output, including `<stem>.verification.json`, byte-identical
- **AND** it SHALL write `<stem>.failed-verification.json` with the failing checks and mismatches, and the error SHALL name that path or say why it could not be written
- **AND** the next successful build SHALL remove the report

#### Scenario: [SDX-MDOC-CREATE-14] --replace rebuilds only the DOCX the last build certified
- **GIVEN** an output directory whose `<stem>.docx` may have been edited after the last build
- **WHEN** `create` runs with `--replace`
- **THEN** it SHALL rebuild only when the DOCX's SHA-256 equals the `docxSha256` of a passing `<stem>.verification.json`
- **AND** an edited DOCX, or one whose certificate is missing, unreadable, failed or has no fingerprint, SHALL be refused with `CREATION_OUTPUT_EDITED`, leaving every file unchanged
- **AND** a DOCX edited or appearing during the build SHALL be caught at publication and the publication rolled back
- **AND** with `--dangerously-overwrite-edited-docx` the build SHALL proceed and keep the exact displaced bytes in a named `.<stem>.create-overwritten-…` directory, and a publication failure SHALL restore the edited DOCX in place
2 changes: 2 additions & 0 deletions openspec/changes/add-markdoc-document-creation/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
- [x] 3.4 Independent round-trip oracle from the Markdoc source, with
negative controls and word-level mismatches (`-11`, #1185).
- [x] 3.5 PDF word-level comparison (`-12`) and the failure report (`-13`).
- [x] 3.6 Edited-DOCX guard for `--replace` and
`--dangerously-overwrite-edited-docx` (`-14`, #1186).

## 4. Benchmark and migration

Expand Down
21 changes: 19 additions & 2 deletions packages/docx-markdoc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,22 @@ replacing the file in that instant. Without
including a PDF from an earlier build, and it never overwrites a file that
appears while it runs. With `--replace`, the outputs are swapped in together,
and a stale artifact this build did not produce (a PDF under `--no-pdf`) is
removed. An output that would overwrite an input, including through a
removed.

`--replace` rebuilds over `<stem>.docx` only when it is the DOCX the last
build certified: its SHA-256 must equal `docxSha256` in a passing
`<stem>.verification.json`. If someone edited the DOCX after the build, or
the certificate is missing or unreadable, the build fails with
`CREATION_OUTPUT_EDITED` and changes nothing. The DOCX is checked again when
it is moved aside at publication, so an edit saved during the build is caught
too. To rebuild anyway, pass `--dangerously-overwrite-edited-docx`. It keeps
the displaced DOCX, byte for byte, in
`.<stem>.create-overwritten-<uuid>/<stem>.docx` and names that path in its
output. These checks see edits saved before publication moves the DOCX aside;
a program still writing to the file after that point writes into the kept
copy.

An output that would overwrite an input, including through a
symlinked directory or a hard link, is refused. Use `--no-pdf` to skip the PDF, or
`--require-pdf` to treat missing tools as a failure. `--style-profile
house.json` overrides any of `font`, `sizePt`, `spacingAfterPt`,
Expand Down Expand Up @@ -665,7 +680,9 @@ brackets-are-blanks reading while you convert, add `fill-ins: brackets` to the
frontmatter. Leftover pseudo-HTML
fails with `LEGACY_MARKUP`, and the error names the tag to use instead.
Make later edits through `docx-markdoc import` / `compile`, which produce
tracked changes.
tracked changes. Once someone edits the `.docx` itself, `--replace` refuses
to rebuild over it (`CREATION_OUTPUT_EDITED`), so a rebuild cannot silently
discard those edits.

### Moving from per-matter pseudo-HTML `.mdoc`

Expand Down
2 changes: 1 addition & 1 deletion packages/docx-markdoc/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ function usage(): string {
' [--note-profile profile.json | --external-notes MODE --internal-notes MODE --unspecified-notes MODE]',
' docx-markdoc verify <anchored.docx> <document.mdoc> [--external-comments|--no-external-comments]',
' docx-markdoc compile-greenfield <template.docx> <document.mdoc> <output-dir> [--style-profile profile.json]',
' docx-markdoc create <document.mdoc> <output-dir> [--style-profile profile.json] [--no-pdf] [--require-pdf] [--replace]',
' docx-markdoc create <document.mdoc> <output-dir> [--style-profile profile.json] [--no-pdf] [--require-pdf] [--replace | --dangerously-overwrite-edited-docx]',
' docx-markdoc export-edits <document.mdoc> <output.json>',
' docx-markdoc comments-to-footnotes <input.docx> <output.docx> [--prefix TEXT] [--prefix-separator TEXT] [--bold-prefix] [--prefix-color RRGGBB] [--prefix-highlight COLOR] [--body-color RRGGBB] [--body-highlight COLOR] [--flatten-threads]',
].join('\n');
Expand Down
94 changes: 90 additions & 4 deletions packages/docx-markdoc/src/create/cli-create.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { randomUUID } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import { link, lstat, mkdir, mkdtemp, open, readFile, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { renderPlainPdf, type PlainPdfVerdict } from '../pdf/plain-render.js';
Expand All @@ -14,14 +14,19 @@ export type CreateCliArgs = {
profilePath?: string;
pdf: boolean;
requirePdf: boolean;
/** Rebuild over existing outputs, but only when the DOCX is the one the last build certified. */
replace: boolean;
/** Also rebuild over an edited or uncertified DOCX, keeping its bytes in a named directory. Implies `replace`. */
overwriteEditedDocx: boolean;
};

/** The PDF verdict as recorded: page texts are dropped, and `words` holds the word-level comparison when the PDF rendered. */
export type RecordedPdfVerdict = Omit<PlainPdfVerdict, 'pageTexts'> & { words?: PdfWordComparison };

export type CreateCliResult = {
outputs: { docx: string; text: string; certificate: string; pdf?: string };
/** Set when `--dangerously-overwrite-edited-docx` displaced a DOCX that was not the last certified build. */
overwritten?: { docx: string; sha256: string; reason: string };
created: CreatedDocument;
pdf: RecordedPdfVerdict | { status: 'skipped' };
summary: string;
Expand Down Expand Up @@ -50,6 +55,7 @@ export function parseCreateCliArgs(args: string[]): CreateCliArgs {
let pdf = true;
let requirePdf = false;
let replace = false;
let overwriteEditedDocx = false;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]!;
if (arg === '--style-profile') {
Expand All @@ -60,13 +66,14 @@ export function parseCreateCliArgs(args: string[]): CreateCliArgs {
} else if (arg === '--no-pdf') pdf = false;
else if (arg === '--require-pdf') requirePdf = true;
else if (arg === '--replace') replace = true;
else if (arg === '--dangerously-overwrite-edited-docx') overwriteEditedDocx = replace = true;
else if (arg.startsWith('--')) throw new Error(`Unknown option ${arg}.`);
else positional.push(arg);
}
const [markdocPath, outputDir] = positional;
if (!markdocPath || !outputDir || positional.length !== 2) throw new Error('create requires a Markdoc file and an output directory.');
if (!pdf && requirePdf) throw new Error('--no-pdf and --require-pdf are mutually exclusive.');
return { markdocPath, outputDir, ...(profilePath ? { profilePath } : {}), pdf, requirePdf, replace };
return { markdocPath, outputDir, ...(profilePath ? { profilePath } : {}), pdf, requirePdf, replace, overwriteEditedDocx };
}

async function existingKind(target: string): Promise<'file' | 'symlink' | 'other' | null> {
Expand All @@ -90,6 +97,45 @@ export function requiredPdfText(created: CreatedDocument): string[] {
return [...new Set([title, lastNeedle, ...footers].filter((value): value is string => Boolean(value?.trim())))];
}

const sha256File = async (target: string): Promise<string> => createHash('sha256').update(await readFile(target)).digest('hex');

/**
* What the last successful build certified about the existing DOCX: its
* SHA-256 from `<stem>.verification.json`, or why there is no usable record.
*/
export type DocxFingerprint = { sha256: string } | { sha256: null; reason: string };

async function certifiedDocxFingerprint(certificatePath: string): Promise<DocxFingerprint> {
if ((await existingKind(certificatePath)) !== 'file') return { sha256: null, reason: `${path.basename(certificatePath)} is missing` };
let record: unknown;
try {
record = JSON.parse(await readFile(certificatePath, 'utf8'));
} catch (error) {
return { sha256: null, reason: `${path.basename(certificatePath)} is unreadable (${error instanceof Error ? error.message : String(error)})` };
}
const { kind, passed, docxSha256 } = (record ?? {}) as { kind?: unknown; passed?: unknown; docxSha256?: unknown };
if (kind !== 'markdoc-create' || passed !== true || typeof docxSha256 !== 'string' || !/^[0-9a-f]{64}$/u.test(docxSha256)) {
return { sha256: null, reason: `${path.basename(certificatePath)} is not a passing create certificate with a DOCX fingerprint` };
}
return { sha256: docxSha256 };
}

/**
* Guard for the existing DOCX. `certified` is the fingerprint the last build
* recorded (null: none usable). Publication hashes the bytes it actually moves
* aside: unless `force`, anything but the certified bytes is refused; with
* `force`, anything but the certified bytes is kept.
*/
type DocxGuard = { target: string; certified: string | null; force: boolean; displacedSha256?: string };

function editedDocxError(target: string, reason: string): DocxMarkdocError {
return new DocxMarkdocError(
'CREATION_OUTPUT_EDITED',
`Refusing to replace ${target}: ${reason}. It may hold edits made after the last build. Move it aside, or pass --dangerously-overwrite-edited-docx to replace it (its bytes are kept in a .<stem>.create-overwritten-… directory).`,
{ target, reason },
);
}

/** Filesystem operations used to publish outputs; injectable so tests can force a mid-publication failure. */
export type PublishFileOps = {
/** Opens the stem lock file (exclusive create). */
Expand Down Expand Up @@ -130,6 +176,7 @@ async function publish(
recoveryDir: string,
replace: boolean,
ops: PublishFileOps,
guard: DocxGuard,
): Promise<void> {
const backups: Array<{ target: string; backup: string }> = [];
const placed: Array<{ target: string; dev: number; ino: number }> = [];
Expand All @@ -140,6 +187,14 @@ async function publish(
const backup = path.join(backupDir, path.basename(target));
await ops.rename(target, backup);
backups.push({ target, backup });
if (target === guard.target) {
// Check the bytes actually moved aside, so an edit made after the
// preflight check is caught too; a failure here restores everything.
guard.displacedSha256 = await sha256File(backup);
if (!guard.force && guard.displacedSha256 !== guard.certified) {
throw editedDocxError(target, 'it changed or appeared during the build and is not the DOCX the last build certified');
}
}
}
for (const [target, staged] of produced) {
// Ownership is the private staged file's inode, read before linking: the
Expand Down Expand Up @@ -335,6 +390,16 @@ async function createLocked(
throw new DocxMarkdocError('CREATION_OUTPUT_EXISTS', `Output already exists: ${target}. Pass --replace to rebuild in place.`);
}
}
// --replace rebuilds only over the DOCX the last build certified. A DOCX with
// no usable certificate, or whose bytes differ from it, may hold edits.
const fingerprint = await certifiedDocxFingerprint(outputs.certificate);
let docxReason: string | undefined;
if ((await existingKind(outputs.docx)) === 'file') {
if (fingerprint.sha256 === null) docxReason = `${fingerprint.reason}, so it cannot be shown to be unedited`;
else if (fingerprint.sha256 !== (await sha256File(outputs.docx))) docxReason = 'its SHA-256 differs from the one recorded by the last build';
if (docxReason && !options.overwriteEditedDocx) throw editedDocxError(outputs.docx, docxReason);
}
const guard: DocxGuard = { target: outputs.docx, certified: fingerprint.sha256, force: options.overwriteEditedDocx };

const profileSource = options.profilePath ? await readFile(options.profilePath) : undefined;
let profile: unknown;
Expand Down Expand Up @@ -396,7 +461,25 @@ async function createLocked(
const backupDir = path.join(staging, 'replaced');
await mkdir(backupDir);
const retired = Object.values(outputs).filter((target) => !produced.has(target));
await publish(produced, retired, backupDir, path.join(outputDir, `.${stem}.create-recovery-${randomUUID()}`), options.replace, ops);
await publish(produced, retired, backupDir, path.join(outputDir, `.${stem}.create-recovery-${randomUUID()}`), options.replace, ops, guard);
// A forced overwrite keeps the displaced DOCX: the exact bytes that were moved aside.
let overwritten: CreateCliResult['overwritten'];
if (guard.displacedSha256 !== undefined && guard.displacedSha256 !== guard.certified) {
const keptDir = path.join(outputDir, `.${stem}.create-overwritten-${randomUUID()}`);
const kept = path.join(keptDir, path.basename(outputs.docx));
try {
await mkdir(keptDir);
await ops.rename(path.join(backupDir, path.basename(outputs.docx)), kept);
} catch (error) {
keepStaging = true;
throw new DocxMarkdocError(
'CREATION_OVERWRITE_BACKUP_FAILED',
`The new outputs were published, but the replaced DOCX could not be moved to ${kept} (${error instanceof Error ? error.message : String(error)}). Its bytes are kept in ${path.join(backupDir, path.basename(outputs.docx))}.`,
{ kept: path.join(backupDir, path.basename(outputs.docx)) },
);
}
overwritten = { docx: kept, sha256: guard.displacedSha256, reason: docxReason ?? 'it changed during the build' };
}
const { checks } = created.certificate;
const summary = [
`created ${path.basename(outputs.docx)}: ${checks.readback.paragraphs} paragraphs, ${checks.footers.sections} section(s)`,
Expand All @@ -406,15 +489,18 @@ async function createLocked(
`brownfield ok (${checks.brownfield.anchoredParagraphs} anchored)`,
`round trip ok (${checks.roundTrip.body.expected} paragraphs; negative controls ok)`,
pdf.status === 'skipped' ? 'pdf skipped' : `pdf ${pdf.status}${'pageCount' in pdf && pdf.pageCount ? ` (${pdf.pageCount} pages)` : ''}${pdf.status === 'not_run' ? ` (${pdf.reason})` : ''}${'words' in pdf && pdf.words ? `; pdf words ok (${pdf.words.expectedWords} source words)` : ''}`,
...(overwritten ? [`WARNING: overwrote an edited ${path.basename(outputs.docx)} (${overwritten.reason}); its previous bytes (sha256 ${overwritten.sha256}) are kept in ${overwritten.docx}`] : []),
].join('; ');
return {
outputs: { docx: outputs.docx, text: outputs.text, certificate: outputs.certificate, ...(produced.has(outputs.pdf) ? { pdf: outputs.pdf } : {}) },
...(overwritten ? { overwritten } : {}),
created,
pdf,
summary,
};
} catch (error) {
keepStaging = error instanceof PublishRecoveryError && error.keepStaging;
// Keep staging when it holds originals that could not be restored or moved out.
keepStaging ||= error instanceof PublishRecoveryError && error.keepStaging;
throw error;
} finally {
if (!keepStaging) await rm(staging, { recursive: true, force: true });
Expand Down
Loading
Loading