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
136 changes: 136 additions & 0 deletions dist-action/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -37560,6 +37560,134 @@ ${translatedContent}`;
}
};

// dist/structural-parity.js
var FLEXIBLE_ARG_DIRECTIVES = /* @__PURE__ */ new Set([
"admonition",
"dropdown",
"card",
"grid-item-card",
"tab-item",
"exercise",
"exercise-start",
"contents",
"index",
"code-cell"
]);
var FLEXIBLE_ARG_PREFIX = "prf:";
function isFlexibleArgDirective(name) {
return FLEXIBLE_ARG_DIRECTIVES.has(name) || name.startsWith(FLEXIBLE_ARG_PREFIX);
}
var FENCE_LINE = /^\s*(`{3,}|~{3,})(.*)$/;
var DIRECTIVE_INFO = /^\{([A-Za-z0-9_+:.-]+)\}\s*(.*)$/;
var ANCHOR_LINE = /^\(([^()\s]+)\)=\s*$/;
function extractStructuralTokens(content) {
const directives = [];
const anchors = [];
let openFence = null;
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const fence = FENCE_LINE.exec(line);
if (openFence) {
if (fence && fence[1][0] === openFence.char && fence[1].length >= openFence.length && fence[2].trim() === "") {
openFence = null;
}
continue;
}
if (fence) {
openFence = { char: fence[1][0], length: fence[1].length };
const info7 = DIRECTIVE_INFO.exec(fence[2].trim());
if (info7) {
directives.push({ name: info7[1], arg: info7[2].trim(), line: i + 1 });
}
continue;
}
const anchor = ANCHOR_LINE.exec(line);
if (anchor) {
anchors.push({ label: anchor[1], line: i + 1 });
}
}
return { directives, anchors };
}
function checkStructuralParity(sourceContent, outputContent) {
const source = extractStructuralTokens(sourceContent);
const output = extractStructuralTokens(outputContent);
const violations = [];
if (source.directives.length !== output.directives.length) {
violations.push({
message: `directive count differs: source has ${source.directives.length}, output has ${output.directives.length} \u2014 a wholesale mismatch usually means the document was re-fenced or truncated`
});
}
const pairCount = Math.min(source.directives.length, output.directives.length);
for (let i = 0; i < pairCount; i++) {
const s = source.directives[i];
const o = output.directives[i];
if (s.name !== o.name) {
violations.push({
message: `directive #${i + 1} name changed: source line ${s.line} has {${s.name}}, output line ${o.line} has {${o.name}}`
});
continue;
}
if (isFlexibleArgDirective(s.name)) {
const sHas = s.arg !== "";
const oHas = o.arg !== "";
if (sHas !== oHas) {
violations.push({
message: `directive #${i + 1} {${s.name}} ${sHas ? "lost" : "gained"} its argument: source line ${s.line} has ${sHas ? `"${s.arg}"` : "no argument"}, output line ${o.line} has ${oHas ? `"${o.arg}"` : "none"}`
});
}
} else if (s.arg !== o.arg) {
violations.push({
message: `directive #${i + 1} {${s.name}} argument changed: source line ${s.line} has "${s.arg}", output line ${o.line} has "${o.arg}" \u2014 structural arguments are never translated`
});
}
}
const sourceLabels = source.anchors.map((a) => a.label);
const outputLabels = output.anchors.map((a) => a.label);
if (sourceLabels.join("\n") !== outputLabels.join("\n")) {
const counts = (labels) => {
const m = /* @__PURE__ */ new Map();
for (const l of labels)
m.set(l, (m.get(l) ?? 0) + 1);
return m;
};
const describe = (label, n) => n > 1 ? `(${label})= \xD7${n}` : `(${label})=`;
const sourceCounts = counts(sourceLabels);
const outputCounts = counts(outputLabels);
const missing = [];
const invented = [];
for (const [label, n] of sourceCounts) {
const d = n - (outputCounts.get(label) ?? 0);
if (d > 0)
missing.push(describe(label, d));
}
for (const [label, n] of outputCounts) {
const d = n - (sourceCounts.get(label) ?? 0);
if (d > 0)
invented.push(describe(label, d));
}
const parts = [];
if (missing.length > 0) {
parts.push(`missing from output: ${missing.join(", ")}`);
}
if (invented.length > 0) {
parts.push(`not in source: ${invented.join(", ")}`);
}
if (parts.length === 0) {
parts.push(`same labels, different order \u2014 source: ${sourceLabels.join(", ")}; output: ${outputLabels.join(", ")}`);
}
violations.push({
message: `target anchors diverge \u2014 ${parts.join("; ")}`
});
}
return { ok: violations.length === 0, violations };
}
function formatParityViolations(filename, result) {
const bullets = result.violations.map((v) => ` - ${v.message}`).join("\n");
return `structural parity check failed for ${filename}:
${bullets}`;
}

// dist/sync-orchestrator.js
var import_fs = require("fs");
var path3 = __toESM(require("path"), 1);
Expand Down Expand Up @@ -37769,6 +37897,10 @@ var SyncOrchestrator = class {
if (!validation.valid) {
throw new Error(`Validation failed: ${validation.error}`);
}
const parity = checkStructuralParity(file.newContent, translatedContent);
if (!parity.ok) {
throw new Error(formatParityViolations(file.filename, parity));
}
this.logger.info(`Successfully processed ${file.filename}`);
result.processedFiles.push(file.filename);
result.translatedFiles.push({
Expand Down Expand Up @@ -37802,6 +37934,10 @@ var SyncOrchestrator = class {
if (!validation.valid) {
throw new Error(`Validation failed: ${validation.error}`);
}
const parity = checkStructuralParity(file.newContent, translatedContent);
if (!parity.ok) {
throw new Error(formatParityViolations(file.filename, parity));
}
this.logger.info(`Successfully processed renamed file ${file.filename}`);
result.processedFiles.push(file.filename);
result.translatedFiles.push({
Expand Down
8 changes: 4 additions & 4 deletions dist-action/index.js.map

Large diffs are not rendered by default.

221 changes: 221 additions & 0 deletions src/__tests__/structural-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
/**
* Structural parity guard (#119, #65; ordering in #120 P0).
*
* Each confirmed production defect in the silent-corruption class appears here
* as a test named for its issue, using the defect's actual shape. The
* legitimate-translation cases (admonition titles, prf: titles) are tested
* just as hard — a guard that cries wolf on every translated title would be
* disabled within a week, which is worse than no guard.
*/

import {
extractStructuralTokens,
checkStructuralParity,
formatParityViolations,
} from '../structural-parity.js';

describe('extractStructuralTokens', () => {
it('collects top-level directives with name and argument', () => {
const doc = [
'# Title',
'',
'```{code-cell} ipython3',
'x = 1',
'```',
'',
'```{raw} jupyter',
'<div></div>',
'```',
].join('\n');

const tokens = extractStructuralTokens(doc);
expect(tokens.directives).toEqual([
{ name: 'code-cell', arg: 'ipython3', line: 3 },
{ name: 'raw', arg: 'jupyter', line: 7 },
]);
});

it('collects target anchors and ignores anchor-like prose', () => {
const doc = [
'(sec:intro)=',
'# Intro',
'',
'Some (parenthetical)= looking text with spaces (a b)=',
'',
'(fig-cobweb)=',
].join('\n');

const tokens = extractStructuralTokens(doc);
expect(tokens.anchors.map((a) => a.label)).toEqual(['sec:intro', 'fig-cobweb']);
});

it('is blind inside open fences — quoted directive examples do not count', () => {
// Documentation that SHOWS directive syntax inside a plain code fence must
// not register: it is content, not structure.
const doc = [
'````',
'```{code-cell} ipython3',
'quoted example',
'```',
'````',
'',
'```{note}',
'real directive',
'```',
].join('\n');

const tokens = extractStructuralTokens(doc);
expect(tokens.directives.map((d) => d.name)).toEqual(['note']);
});

it('does not record anchors inside fences', () => {
const doc = ['```', '(not-an-anchor)=', '```', '(real)='].join('\n');
expect(extractStructuralTokens(doc).anchors.map((a) => a.label)).toEqual(['real']);
});

it('handles tilde fences', () => {
const doc = ['~~~{warning}', 'body', '~~~'].join('\n');
expect(extractStructuralTokens(doc).directives).toEqual([
{ name: 'warning', arg: '', line: 1 },
]);
});
});

describe('checkStructuralParity — the confirmed defect shapes', () => {
const SOURCE = [
'(sec:model)=',
'# The Model',
'',
'```{raw} jupyter',
'<div class="cell"></div>',
'```',
'',
'```{code-cell} ipython3',
'import numpy as np',
'```',
].join('\n');

it('passes a faithful translation', () => {
const output = SOURCE.replace('# The Model', '# 模型').replace('import numpy', 'import numpy');
expect(checkStructuralParity(SOURCE, output).ok).toBe(true);
});

it('#119: a stripped directive argument ({raw} jupyter -> {raw}) fails', () => {
const output = SOURCE.replace('```{raw} jupyter', '```{raw}');
const result = checkStructuralParity(SOURCE, output);
expect(result.ok).toBe(false);
expect(result.violations.some((v) => v.message.includes('{raw}'))).toBe(true);
expect(result.violations.some((v) => v.message.includes('never translated'))).toBe(true);
});

it('#65: a dropped (label)= anchor fails, naming the label', () => {
const output = SOURCE.replace('(sec:model)=\n', '');
const result = checkStructuralParity(SOURCE, output);
expect(result.ok).toBe(false);
expect(result.violations.some((v) => v.message.includes('(sec:model)='))).toBe(true);
expect(result.violations.some((v) => v.message.includes('missing from output'))).toBe(true);
});

it('#118: a document wrapped whole in a code fence fails on count', () => {
// The resync wrapped 1,465 lines in one fence; every real directive
// becomes invisible content and the top-level sequence collapses.
const output = '```\n' + SOURCE + '\n```';
const result = checkStructuralParity(SOURCE, output);
expect(result.ok).toBe(false);
expect(result.violations.some((v) => v.message.includes('directive count differs'))).toBe(true);
});

it('a renamed directive (note -> warning) fails even with args empty', () => {
const src = '```{note}\nbody\n```';
const out = '```{warning}\nbody\n```';
const result = checkStructuralParity(src, out);
expect(result.ok).toBe(false);
expect(result.violations[0].message).toContain('name changed');
});

it('an invented anchor fails — the output cannot add cross-reference targets', () => {
const output = SOURCE + '\n(sec:invented)=\n';
const result = checkStructuralParity(SOURCE, output);
expect(result.ok).toBe(false);
expect(result.violations.some((v) => v.message.includes('not in source'))).toBe(true);
});

it('reordered anchors fail even when the label set matches', () => {
const src = '(a)=\n# One\n\n(b)=\n## Two';
const out = '(b)=\n# 一\n\n(a)=\n## 二';
const result = checkStructuralParity(src, out);
expect(result.ok).toBe(false);
expect(result.violations.some((v) => v.message.includes('different order'))).toBe(true);
});

it('a dropped DUPLICATE anchor is reported as missing, not as reordering', () => {
// includes()-based diffing would see every label present and mislabel this
// as "different order". The multiset diff must name the dropped copy.
const src = '(a)=\n# One\n\n(a)=\n## Two\n\n(b)=\n### Three';
const out = '(a)=\n# 一\n\n(b)=\n### 三';
const result = checkStructuralParity(src, out);
expect(result.ok).toBe(false);
const anchorViolation = result.violations.find((v) => v.message.includes('anchors diverge'));
expect(anchorViolation?.message).toContain('missing from output: (a)=');
expect(anchorViolation?.message).not.toContain('different order');
});

it('a duplicated-in-output anchor is reported as not-in-source with its count', () => {
const src = '(a)=\n# One';
const out = '(a)=\n# 一\n\n(a)=\n## 二\n\n(a)=\n### 三';
const result = checkStructuralParity(src, out);
expect(result.ok).toBe(false);
const anchorViolation = result.violations.find((v) => v.message.includes('anchors diverge'));
expect(anchorViolation?.message).toContain('not in source: (a)= ×2');
});
});

describe('checkStructuralParity — legitimate translation must pass', () => {
it('a translated admonition title passes (presence matches)', () => {
const src = '```{admonition} Exercise 1\nTry it.\n```';
const out = '```{admonition} 练习 1\n试一试。\n```';
expect(checkStructuralParity(src, out).ok).toBe(true);
});

it('a translated prf:theorem title passes', () => {
const src = '```{prf:theorem} Contraction Mapping\nbody\n```';
const out = '```{prf:theorem} 压缩映射\nbody\n```';
expect(checkStructuralParity(src, out).ok).toBe(true);
});

it('a DROPPED admonition title still fails — presence must match', () => {
const src = '```{admonition} Exercise 1\nTry it.\n```';
const out = '```{admonition}\n试一试。\n```';
const result = checkStructuralParity(src, out);
expect(result.ok).toBe(false);
expect(result.violations[0].message).toContain('lost its argument');
});

it('a solution argument is structural — changing it fails', () => {
// {solution} takes the target exercise's LABEL, not a title.
const src = '```{solution} ex-cobweb\nbody\n```';
const out = '```{solution} ex-蛛网\nbody\n```';
const result = checkStructuralParity(src, out);
expect(result.ok).toBe(false);
expect(result.violations[0].message).toContain('argument changed');
});

it('an untranslated structural doc passes byte-for-byte', () => {
const doc = '```{include} _admonition/gpu.md\n```';
expect(checkStructuralParity(doc, doc).ok).toBe(true);
});
});

describe('formatParityViolations', () => {
it('names the file and lists every violation', () => {
const src = '(a)=\n```{raw} jupyter\nx\n```';
const out = '```{raw}\nx\n```';
const result = checkStructuralParity(src, out);
const text = formatParityViolations('cobweb.md', result);
expect(text).toContain('cobweb.md');
expect(result.violations.length).toBeGreaterThanOrEqual(2);
for (const v of result.violations) {
expect(text).toContain(v.message);
}
});
});
Loading
Loading