Skip to content

feat: check that zh and ja pages mirror the English page's structure - #1042

Merged
RiskeyL merged 31 commits into
mainfrom
chore/translation-parity-check
Sep 11, 2026
Merged

RiskeyL merged 31 commits into
mainfrom
chore/translation-parity-check

Conversation

@RiskeyL

@RiskeyL RiskeyL commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

A paragraph present in the zh and ja pages and absent from the English one passed every linter, the link check, and two merged commits before an external reviewer caught it. An anchor divergence in the same release passed hunk-count parity. No check looked across languages.

tools/check-parity.py splits each English page at its headings and compares every section with its zh and ja twins: heading count and level, then paragraphs, list items, code blocks, table rows, components, tabs, and anchor ids. Heading text is translated and not compared; the translation disclaimer is skipped. It ends with PARITY OK or one line per mismatch and exit 1.

The format-check skill runs it after the two linters for the English files in scope. On the members page as it stood before the 1.17.1 retranslation it reports 22 mismatches; the shipped page passes. Across the corpus today it reports 407 mismatches on 141 of 291 pages, so a mismatch outside the sections a round touched is reported as pre-existing rather than fixed in passing.

Refs DC-298.

The editor test judged whole pages, so a round that changed three paragraphs on a release branch got a verdict on the page's standing debt. Its dispatch now names the changed sections, the judge still reads the whole page, and marks inside and outside the change are reported apart, with the verdict on this round's work. The judge reads the whole style guide, and a mark can name the rule it breaks.

A translation test gives zh and ja pages the same fresh-judge check beside the English source, against the language's guide and the glossary; S7 runs it instead of exempting translations.

Dispatch counts are agreed with the owner before any drafter, editor, or reader is sent; one per page is a choice, not the default. Reader-test findings are relayed and proposed per item before anything reaches the page.

The research summary has a shape of its own, in references/research-summary.md: a contract that the sheet is the ceiling and not a checklist, sections opened by the reader's question, background facts marked, and the two closing lists. The session owns what the page does not carry; the drafter keeps room to leave out sentence by sentence, and may read the pages this one links to. S6 looks for an existing translation in the sibling audience tree before translating.

Style guide: switches are written as the change the reader might make, and a cross-reference keeps the reader as its subject.
A paragraph present in the zh and ja pages and absent from the English one passed every linter, the link check, and two merged commits before an external reviewer caught it; an anchor divergence in the same release passed hunk-count parity. Neither check looks across languages.

tools/check-parity.py splits each English page at its headings and compares every section with the zh and ja twins: heading count and level, then paragraphs, list items, code blocks, table rows, components, tabs, and anchor ids. Heading text is translated and not compared; the translation disclaimer is skipped. It ends with PARITY OK or one line per mismatch and exit 1. On the members page as it stood before the 1.17.1 retranslation it reports 22 mismatches; the shipped page passes.

The format-check skill runs it after the two linters for the English files in scope. Across the corpus today it reports 407 mismatches on 141 of 291 pages, so a mismatch outside the sections a round touched is reported to the owner as pre-existing rather than fixed in passing.
Copilot AI lite review requested due to automatic review settings September 11, 2026 10:35
@mintlify

mintlify Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
dify-ce-cloud-docs 🟢 Ready View Preview Sep 11, 2026, 5:31 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Critical parity-checker coverage and pre-existing-mismatch handling issues remain unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds cross-language structural parity checking for English, Chinese, and Japanese documentation, alongside updates to writing and review workflows.

Changes:

  • Adds tools/check-parity.py for structural comparisons.
  • Integrates parity, translation, reader, and editor checks.
  • Updates style, research-summary, and drafting guidance.
File summaries
File Description
writing-guides/style-guide.md Adds switch/default and cross-reference guidance.
writing-guides/index.md Updates verification workflow steps.
tools/check-parity.py Implements cross-language structure checks.
.claude/skills/dify-docs-write/SKILL.md Updates the documentation workflow.
.claude/skills/dify-docs-write/references/research-summary.md Defines the research-summary format.
.claude/skills/dify-docs-write/references/drafting-turn.md Clarifies drafting constraints.
.claude/skills/dify-docs-translation-test/SKILL.md Adds translation review procedures.
.claude/skills/dify-docs-reader-test/SKILL.md Adds multi-page reader-test guidance.
.claude/skills/dify-docs-format-check/SKILL.md Integrates parity checking.
.claude/skills/dify-docs-editor-test/SKILL.md Updates editor-test dispatch and scope handling.
Review details

Suppressed comments (4)

.claude/skills/dify-docs-reader-test/SKILL.md:16

  • Allowing one reader to process multiple pages sequentially breaks the clean-context/first-time-reader premise: after page A, the same agent has learned terminology and navigation that can make page B appear self-contained. Use a fresh subagent per page, or explicitly reset the agent context between pages; the current prompt cannot provide that isolation.
2. **Agree the dispatch with the owner**: how many readers, and which pages each reads. A page written or rewritten whole gets its own reader. A round that changed parts of several pages can send them to one reader, who reads each whole; the reader is never told what changed, because a first-time reader does not know either. One reader per page is a choice, not the default. When no reviewer is in the session, state the dispatch in the report or PR description and proceed.

.claude/skills/dify-docs-write/references/research-summary.md:66

  • drafting-turn.md now makes the summary a hard ceiling ('Nothing off it goes on the page'), but this example explicitly approves adding a consequence the summary did not state. A drafter following both instructions cannot tell whether this reader-oriented consequence is allowed, and could either add an unverified fact or omit the intended judgment. Clarify that the sentence is an inference from listed facts, or include the consequence in the verified summary.
The two background facts stayed off the page. The fix for each reason went in whole, because that is the judgment the reader came for. The last bullet became a sentence with a consequence the summary did not state, because the drafter asked what the reader would do with the fact.

.claude/skills/dify-docs-write/references/research-summary.md:14

  • The contract says every fact below is verified, but the template's own Not on this page list explicitly permits an Unverified entry, and the writing workflow requires unverified claims to be reported. Clarify that only facts proposed for inclusion must be verified, with unverified claims recorded separately, so the researcher is not told both to include and exclude them.
Every fact below is verified. Add no fact that is not on this sheet, and
do not state every fact that is: a fact goes on the page because the
reader would ask for it at that section. Facts marked "background" are
here so you understand the product; the reader would not ask for them, so
they stay off the page.

tools/check-parity.py:138

  • The parity input filter accepts only .mdx, while the format-check procedure explicitly treats both .mdx and .md as in-scope and passes those paths here. A Markdown page is silently omitted (or yields no English pages when it is the only input), so its zh/ja twin is never checked. Support .md consistently in the filter and --all path, or reject unsupported inputs explicitly.
    pages = [p for p in pages if p.is_file() and p.suffix == ".mdx" and (REPO / "en") in p.parents]
  • Files reviewed: 10/10 changed files
  • Comments generated: 6
  • Review effort level: Lite

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .claude/skills/dify-docs-format-check/SKILL.md Outdated
Comment thread tools/check-parity.py Outdated
Comment thread tools/check-parity.py Outdated
Comment thread tools/check-parity.py Outdated
Comment thread tools/check-parity.py Outdated
Comment thread .claude/skills/dify-docs-format-check/SKILL.md Outdated
A reader group holds only pages a reader would reach in sequence, in navigation order, with one persona. The translation test's term tag no longer reads as marking every correct translation, and its rule tag covers translation rules only. Sibling-translation reuse is scoped to use-dify pages and requires the sibling's English to match this page's, since the two trees can sit on different releases. The summary's verified guarantee covers facts under section headings; the closing list may record unverified claims so they are reported, never stated. The ceiling bounds product facts, and what the reader should do with a fact is the drafter's to say. The UI-visible list keeps what the style guide says to repeat. The outline is the summary's question-headed sections; the index notes the API-spec exception.
…ents

Every mismatch failed the run, so a page carrying older drift could never print PARITY OK however clean the round's own sections were. With --base, the same comparison runs at that ref and only new mismatches count; the rest are listed as pre-existing. The format-check step passes every changed page in any language, since a zh or ja path is checked through its English twin, so a translation-only change is covered.

Headings indented inside components are now read, component names are compared as a sequence rather than a count, id attributes on tags count as anchors, and .md pages are accepted.
Copilot AI review requested due to automatic review settings September 11, 2026 10:55
…e parity check

A component inside a table cell or a list item was invisible to the check, so removing one from a translation passed. Tags are now collected anywhere on a line outside code fences. A page argument that is not a page under en/, zh/, or ja/ is a usage error rather than a silent skip.
The glossary's UI Labels rows can be stale, and the translation judge has no way to know which. General terms are still checked against the glossary; a UI label is compared with the label the English page names, which the terminology check verifies against the product.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The parity checker has unresolved coverage and baseline-handling defects, including one critical finding.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (9)

tools/check-parity.py:40

  • HEADING_RE only recognizes Markdown # headings, but --all includes the custom/frame pages whose structure uses HTML <h1>/<h2> tags (for example, en/home.mdx and en/cloud/use-dify/getting-started/introduction.mdx). Those lines fall through to the generic tag branch, so adding, removing, or changing the level of an HTML heading in a translation leaves the section count and levels unchanged and passes parity. Parse HTML heading tags as sections too, or explicitly exclude these page modes from this check.
HEADING_RE = re.compile(r"^\s*(#{1,6})\s+(.*?)\s*$")
CUSTOM_ID_RE = re.compile(r"\{#([\w-]+)\}")
TAG_ID_RE = re.compile(r"""\bid=["']([\w-]+)["']""")

tools/check-parity.py:158

  • Unlike read_working, this subprocess relies on the process locale to decode Git output because no encoding is specified. On a non-UTF-8 locale, reading any Chinese or Japanese base page can raise UnicodeDecodeError and abort the parity check; set the subprocess encoding explicitly to UTF-8.
    proc = subprocess.run(
        ["git", "-C", str(REPO), "show", f"{ref}:{rel}"], capture_output=True, text=True
    )

tools/check-parity.py:95

  • Anchor extraction is limited to the opening line of a component tag. The docs already use multiline tags such as <Accordion followed by attribute lines, so an id on a later line is counted as prose and never added to anchors; changing or removing that stable component ID between languages will therefore pass parity. Parse the complete multiline tag before extracting its attributes.
        c = COMPONENT_RE.match(line)
        if c:
            cur.components.append(c.group(1))
            cur.anchors.extend(TAG_ID_RE.findall(line))
            in_para = False

tools/check-parity.py:43

  • This separator regex only checks the beginning of the first cell, so a legitimate data row such as | ---draft | ... | is classified as the separator and omitted from table rows. A row added or removed in that form can pass without a mismatch; require every cell to match the separator pattern through the end of the row.
TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{3,}")

tools/check-parity.py:88

  • Only the number of table rows is stored; cell boundaries are discarded. A translation can add a column or omit a cell in every row and still produce the same table rows count, so a real table-shape divergence is reported as PARITY OK. Track each row's cell count (or another normalized row shape) and compare it per section.
        if TABLE_ROW_RE.match(line):
            if not TABLE_SEP_RE.match(line):
                cur.counts["table rows"] += 1

tools/check-parity.py:103

  • list items records only a total count and loses indentation and marker type. Re-nesting an item, or changing a flat list into an item with a nested subitem, leaves the count unchanged and lets the rendered structure diverge without an issue. Record the normalized list nesting/block shape rather than only the total.
        if LIST_RE.match(line):
            cur.counts["list items"] += 1
            in_para = False

tools/check-parity.py:207

  • The baseline subtraction is based on exact output strings, but each section issue embeds its numeric section {i} (and its counts). Adding or reordering a heading before an untouched, pre-existing mismatch changes that string, so the old drift is classified as new and can block the structural edit. Compare normalized mismatch identities after mapping sections, rather than subtracting raw rendered lines.
            seen = Counter(before)

tools/check-parity.py:198

  • The format-check instructions derive <changed pages...> from git diff --name-only, which includes deleted files. If an English page and both language twins are intentionally removed together, no working English file exists and this emits missing English page, so the deletion cannot pass parity. Distinguish a page deleted in all languages from a missing English twin before adding an issue.
        en_text = read_working(en)
        if en_text is None:
            new.append(f"{en}: missing English page")
            continue

tools/check-parity.py:108

  • Indented continuation lines of a Markdown list item are classified as paragraphs by the fallback below. For example, en/cli/integrate-agents/overview.mdx wraps list-item text onto indented lines while its zh/ja twins keep the text on the marker line; these equivalent list structures produce different paragraph counts and parity noise. Track list-item continuations/nested blocks or parse the Markdown structure instead of treating every non-marker line as a new paragraph.
        if not in_para:
            cur.counts["paragraphs"] += 1
            in_para = True
  • Files reviewed: 10/10 changed files
  • Comments generated: 3
  • Review effort level: Lite

Comment thread tools/check-parity.py Outdated
Comment thread tools/check-parity.py
Comment thread tools/check-parity.py
Copilot AI review requested due to automatic review settings September 11, 2026 11:05
…the summary contract

The translation judge could only quote sentences in the draft, so an English sentence or section with no counterpart went unmarked; it is now marked as meaning, quoting the English. The summary's verified guarantee covers the question-headed sections, and the closing lists hold what stays off the page, unverified claims included. The research skill's Phase 3 says its record is research output, and that S3 writes the drafter's summary from it.
… headings

A misspelled or absent base ref was treated as no baseline, so every mismatch counted as new; the ref is verified once and a bad one is a usage error. A heading-count mismatch skipped every section, which with a base ref hid new drift inside sections that still aligned; sections are compared in order as far as they align. Component tags inside heading lines, where plan badges live, are now part of the section's component sequence.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The parity checker has unresolved review findings, including a critical path-validation issue.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (11)

Previously missed (1) — in code that hasn't changed since the last review.

tools/check-parity.py:84

  • This branch creates the new section and immediately continues without recording tags from the heading line. Existing pages put Mintlify components in headings (for example <Badge> in en/cli/common-tasks.mdx:77), so adding or removing that component in a translation is invisible to the advertised component-parity check. Extract INLINE_TAG_RE and TAG_ID_RE from the heading before continuing.

tools/check-parity.py:162

  • read_at treats an invalid or unfetched base ref exactly like a missing file. With the documented --base origin/main, a missing ref makes before empty, so pre-existing drift is reported as newly introduced (or a clean current tree reports PARITY OK without any baseline comparison). Validate the base ref once and fail with a clear usage error instead of silently weakening the gate.

def read_at(ref: str, rel: str) -> str | None:
    proc = subprocess.run(
        ["git", "-C", str(REPO), "show", f"{ref}:{rel}"], capture_output=True, text=True
    )

tools/check-parity.py:107

  • The generic custom-anchor extraction is after the LIST_RE early return, and no TAG_ID_RE extraction occurs on list lines. Therefore an explicit {#...} or HTML id attached to a list item is invisible to the parity check, allowing that anchor to diverge without an issue. Extract both anchor forms in the list branch before continuing.
            in_para = False
            continue
        if LIST_RE.match(line):
            cur.counts["list items"] += 1

tools/check-parity.py:145

  • This comparison deliberately sorts the anchor lists, so it ignores anchor order. If a section contains two explicit anchors and a translation moves them to the other blocks, the same IDs still compare equal even though deep links land on different content. Compare the anchors in document order (or associate each anchor with its block) rather than sorting them.
                )

tools/check-parity.py:96

  • TAG_ID_RE is applied only to the line containing the opening component. The repository uses multiline JSX tags (for example, getting-started-dify-plugin.mdx:40-44), so an id placed on a continuation line is never added to anchors; a stable-anchor divergence in that form will pass this check. Parse the complete opening tag before extracting its attributes.
            in_para = False
            continue
        if COMPONENT_RE.match(line):

tools/check-parity.py:217

  • The baseline filter matches fully formatted strings that include the numeric section i label. Adding a matching heading to English and its twin before an untouched pre-existing mismatch changes that label, so the same old mismatch is treated as new instead of being listed under pre-existing. Compare stable mismatch identities independent of section numbering, or retain section identity across the baseline/current parse.
            new.append(f"{en}: missing English page")
            continue
        rest = en.split("/", 1)[1]
        now = compare_texts(en, en_text, {lang: read_working(f"{lang}/{rest}") for lang in TWINS})
        if args.base:

tools/check-parity.py:103

  • This branch discards every line that starts with < after only recording id attributes, so standalone raw HTML content such as <a href="...">Learn more</a> (used in develop-plugin/getting-started/getting-started-dify-plugin.mdx:47) contributes no paragraph or component count. Removing that link from zh/ja therefore passes parity. Distinguish wrapper tags from content-bearing HTML and count/compare the latter as a block.
            continue
        cur.components.extend(INLINE_TAG_RE.findall(line))
        if line.lstrip().startswith("<"):
            cur.anchors.extend(TAG_ID_RE.findall(line))

tools/check-parity.py:183

  • *.md* also matches non-page files such as page.md.bak and page.mdx~. With --all, any such artifact under en/ is treated as a page and then produces spurious missing-twin issues. Restrict this scan to the documented .md and .mdx suffixes.
        ok = subprocess.run(

tools/check-parity.py:105

  • All list markers are collapsed into the single list items counter, so an English numbered procedure and a translated unordered list with the same item count compare equal. That changes the structure and sequence semantics while producing no parity issue. Track ordered and unordered markers separately (ideally preserving their sequence).
            continue

tools/check-parity.py:69

  • FENCE_RE toggles on every fence marker, regardless of which marker opened the block. A valid code block can contain the other marker (for example, a ``` block containing a ~~~ line); after that line, headings/components in the remainder of the code are parsed as document structure, so parity results depend on code-sample contents. Track the opener's marker and length and only close on a matching fence.
        cur = out[-1]
        if FENCE_RE.match(line):
            if not in_fence:
                cur.counts["code blocks"] += 1

tools/check-parity.py:111

  • After a list item resets in_para to False, any indented continuation line reaches this block and is counted as a new paragraph. Markdown treats a continuation without a blank line as part of the list item's paragraph, so language-specific wrapping can create parity mismatches even when the structure mirrors. Track list-item continuation/indentation instead of resetting paragraph state this way.
            continue
        cur.anchors.extend(CUSTOM_ID_RE.findall(line))
        if not in_para:
  • Files reviewed: 11/11 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread tools/check-parity.py Outdated
Comment thread tools/check-parity.py Outdated
Copilot AI review requested due to automatic review settings September 11, 2026 11:16
Inputs are resolved against the repository root before the language check, so a ./ prefix is accepted and a path that escapes the language trees is rejected. The input no longer has to exist: a deleted translation is reported as a missing twin, and a misspelled English path as a missing English page, rather than stopping the run before the comparison.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The parity checker has multiple moderate gaps that can miss or misclassify documented cross-language drift.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (8)

Previously missed (1) — in code that hasn't changed since the last review.

tools/check-parity.py:99

  • For a multiline component, TAG_ID_RE is applied only to the opening line. A valid id="..." on a later attribute line of <Tab> or <Accordion> is therefore missed, even though this repository uses multiline component attributes (for example, en/develop-plugin/getting-started/getting-started-dify-plugin.mdx:40). A stable anchor added in one language would pass the anchor comparison; parse the complete tag span before extracting IDs.

tools/check-parity.py:99

  • This branch treats a component opening line as a leaf and never parses its body. Existing pages use one-line blocks such as <Info>text</Info> and <Note>text</Note>, so adding or removing a paragraph inside an existing component in zh/ja leaves the component sequence and all counters unchanged and can pass this check. Parse inline component bodies (or use an MDX parser) before comparing block counts.
        if COMPONENT_RE.match(line):
            cur.components.extend(INLINE_TAG_RE.findall(line))
            cur.anchors.extend(TAG_ID_RE.findall(line))
            in_para = False

tools/check-parity.py:93

  • HTML lists are not counted here. The corpus uses <ul><li> lists inside table cells (for example, en/self-host/use-dify/nodes/human-input.mdx), but this branch only increments table rows and extracts uppercase component names, so adding or removing a <li> does not change any compared value. Count HTML list items (including their nesting) or parse the table cell structure.
        if TABLE_ROW_RE.match(line):
            if not TABLE_SEP_RE.match(line):
                cur.counts["table rows"] += 1
            cur.components.extend(INLINE_TAG_RE.findall(line))
            cur.anchors.extend(TAG_ID_RE.findall(line))

tools/check-parity.py:197

  • For an absolute page path outside REPO, Path(raw).resolve().relative_to(REPO) raises ValueError before the normal validation below, producing an uncaught traceback instead of the documented usage error. Handle paths outside the repository and return the same exit-2 diagnostic used for other invalid page arguments.
            given = Path(raw)

tools/check-parity.py:104

  • Any line whose first non-whitespace character is < is discarded as non-content. That includes real one-line HTML paragraphs such as <p>aaPanel 7.0.11 or later</p> (present in all three language copies), so adding or removing such a paragraph can leave both the paragraph count and component sequence unchanged. Parse block HTML content instead of treating every tag-starting line as empty.
        cur.components.extend(INLINE_TAG_RE.findall(line))
        if line.lstrip().startswith("<"):
            cur.anchors.extend(TAG_ID_RE.findall(line))
            in_para = False

tools/check-parity.py:55

  • Only aggregate counters are retained for paragraphs, lists, code blocks, and table rows. A twin can therefore reorder a paragraph and a list (or a code block and a paragraph) while all counters, component names, and anchors remain equal, producing no issue even though the section's rendered block structure differs. Retain and compare an ordered block-kind sequence per section instead of only totals.
class Section:
    def __init__(self, level: int) -> None:
        self.level = level
        self.counts: Counter = Counter()
        self.components: list[str] = []

tools/check-parity.py:199

  • The format-check procedure passes changed documentation paths to this command, and git diff --name-only includes deleted or renamed pages. Requiring the input path itself to exist makes a deleted zh/ja page return usage error 2 before it can be reported as a missing twin (and deleting all three pages also aborts). Accept deleted inputs and handle current/base existence explicitly, or skip deletions, so valid page deletions do not break the parity gate.
            try:

tools/check-parity.py:146

  • Sorting the IDs makes this comparison set-like and misses a real divergence: if two <Tab> elements in one section swap their id values, the sorted lists remain equal even though links such as #swagger-api now open the wrong tab. Compare the ordered IDs or associate each ID with its component so the target mapping is checked.
            if sorted(a.anchors) != sorted(b.anchors):
  • Files reviewed: 11/11 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread tools/check-parity.py
Copilot AI review requested due to automatic review settings September 11, 2026 11:24
The editor test runs after translation, and a correction it produces can change the English. The page now returns to S6 before the translation test, so the zh and ja pages are judged as they will ship. The research skill presents its record under that name.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The parity checker has unresolved correctness and coverage gaps in HTML/JSX, anchors, section alignment, and encoding.

Review details

Suppressed comments (8)

tools/check-parity.py:107

  • This branch drops all content on lines that start with < instead of parsing text-bearing HTML/JSX. The corpus contains inline paragraphs such as en/self-host/deploy/platform-guides/bt-panel.mdx:25 and custom pages whose headings are <h1>/<h2>, so adding or removing one of these blocks in only zh/ja produces no parity signal. Parse these elements (including HTML headings), or explicitly exclude custom/frame pages from this check; otherwise the advertised paragraph/heading coverage is incomplete.
        cur.components.extend(INLINE_TAG_RE.findall(line))
        if line.lstrip().startswith("<"):
            cur.anchors.extend(TAG_ID_RE.findall(line))
            in_para = False

tools/check-parity.py:264

  • The base-diff filter compares the fully rendered mismatch strings, but those strings include the current English heading text. If an existing drifted section is merely renamed in this PR, before is labeled with the old heading and now with the new heading, so the Counter does not subtract it and reports unchanged drift as a new issue. Compare stable section identities before formatting the message (for example, an explicit anchor or a revision-stable section key) to preserve the pre-existing-mismatch contract.
            seen = Counter(before)
            for line in now:
                if seen[line]:
                    seen[line] -= 1

tools/check-parity.py:181

  • read_working decodes pages as UTF-8, but this git show call leaves subprocess.run(text=True) on the host locale. In a non-UTF-8 locale, reading a CJK twin at the base ref can raise UnicodeDecodeError (or decode to mojibake), so the format check fails before reporting parity. Pass encoding="utf-8" here to match the page-file reads.
        ["git", "-C", str(REPO), "show", f"{ref}:{rel}"], capture_output=True, text=True

tools/check-parity.py:48

  • Only opening component tags are recorded: INLINE_TAG_RE cannot match </...>, and the comparison never validates nesting. Removing a closing </Tab> (or replacing it with the wrong closing component) therefore leaves the opening sequence and all counters unchanged, so a broken twin can still return PARITY OK. Include closing/self-closing component tokens or balance the component tree before comparing.
INLINE_TAG_RE = re.compile(r"<([A-Z][A-Za-z]*)\b")

tools/check-parity.py:107

  • Anchor extraction is conditional on the line being a table row, a component opener, or starting with <. An explicit anchor embedded in normal prose, such as text <a id="stable"></a>, is counted only as a paragraph and its ID is never added; a translation can omit or change it without a mismatch. Extract TAG_ID_RE matches from every non-fenced line (and handle multiline tags) before these branches.
        cur.components.extend(INLINE_TAG_RE.findall(line))
        if line.lstrip().startswith("<"):
            cur.anchors.extend(TAG_ID_RE.findall(line))
            in_para = False

tools/check-parity.py:155

  • When a twin inserts or omits a heading, this index-based zip pairs every later twin section with a different English section. The heading-count warning does not prevent the resulting count/component/anchor differences from being emitted as real mismatches, and the base diff can then treat pre-existing drift as newly introduced. Align the section streams before comparing, or stop per-section comparison after the first divergence.
        for i, (a, b) in enumerate(zip(en, tw)):
            where = names[i]

tools/check-parity.py:102

  • TAG_ID_RE is applied only to the current component line. Existing pages use multiline <Accordion ...> openings (for example, en/develop-plugin/getting-started/getting-started-dify-plugin.mdx:40-44), so an id attribute on a following prop line is ignored; a stable Accordion anchor can diverge without being reported. Accumulate the full opening tag or scan continuation attribute lines before extracting IDs.
        if COMPONENT_RE.match(line):
            cur.components.extend(INLINE_TAG_RE.findall(line))
            cur.anchors.extend(TAG_ID_RE.findall(line))
            in_para = False

tools/check-parity.py:169

  • Sorting anchor IDs makes this check set-based even though IDs are attached to particular components. Swapping the IDs of two sibling <Tab>s preserves the sorted list, so links can resolve to the wrong tab while parity passes. Compare a.anchors and b.anchors in encounter order.
            if sorted(a.anchors) != sorted(b.anchors):
                issues.append(
                    f"{trel}: {where}: anchor ids {','.join(sorted(b.anchors)) or 'none'}, "
                    f"en {','.join(sorted(a.anchors)) or 'none'}"
  • Files reviewed: 11/11 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

The translation test's reply names the missing English unit as well as a quoted sentence. A round that rewrote several pages as one topic can send them to one reader in navigation order. The sibling-translation match is on shared content, with the audience block an allowed difference. Any English correction accepted in the check stage returns the page to S6 and reruns the translation test. The terminology check counts as clean when its only output is the glossary's dead-row report. Background facts are named as the third exclusion. The research skill's closing rule defers to the pipeline's no-reviewer rule. The style guide's switch rule says the current state, not the default, is visible.
…able cells, HTML blocks, and block order

Anchor ids are read from every line outside code fences and from the continuation lines of a tag opened earlier, and compared in encounter order, so a swapped pair of tab ids is reported. Blockquoted list items count as items. Table cells are counted as well as rows. Lowercase HTML blocks such as video and h2 join the component sequence. When counts and components agree but the blocks come in a different order, that is reported. Section labels strip whole tags. Base-ref files are decoded as UTF-8, and an orphan translation already orphaned at the base is pre-existing.
Copilot AI review requested due to automatic review settings September 11, 2026 13:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved parity-checker and translation-review issues need to be addressed.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (4)

.claude/skills/dify-docs-translation-test/SKILL.md:44

  • The prompt tells the judge to mark any translated sentence that says something the English does not, but every non-custom zh/ja page intentionally has the extra translation disclaimer required by the language guide. Without an explicit exemption, a fresh judge can report this required boilerplate as a meaning failure on every page and distort the verdict. Tell the judge to ignore the standard disclaimer before applying the meaning rule.
Mark every sentence that falls short, quoting it, with one tag each. A
sentence, block (a list, table, code block, callout, or image), or section
of the English page with no counterpart in the translation is marked too,
quoting the English:

tools/check-parity.py:285

  • Base-drift suppression compares the rendered issue strings, and those strings include the English section label produced by labels_for. If this round renames an English heading, an unchanged pre-existing mismatch changes from section "Old" to section "New" and is counted in new, so the parity check fails on drift that the base already had. Compare using a stable section identity independent of display heading text, keeping the English label only for reporting.
            seen = Counter(before)
            for line in now:
                if seen[line]:
                    seen[line] -= 1
                    old.append(line)
                else:
                    new.append(line)

tools/check-parity.py:126

  • LIST_RE preserves only the fact that a line is a list item; blocks records every item as item and drops indentation (and ordered versus unordered markers). A translation can flatten a nested list while keeping the same item count, so the checker reports parity even though the rendered hierarchy changes; the formatting guide requires two-space nesting. Include nesting depth in the per-item structural signature.
        if LIST_RE.match(line):
            cur.counts["list items"] += 1
            cur.blocks.append("item")
            in_para = False

tools/check-parity.py:127

  • This is line-based rather than Markdown-block-based: a wrapped list item such as - first line followed by an indented continuation does not match LIST_RE, so lines 129–132 add a para block for the continuation. If English wraps an item differently from zh/ja (which is common after translation), equivalent list structure is reported as a paragraph mismatch. Consume indented continuation lines as part of the current list item, or parse the Markdown structure before comparing.
        if LIST_RE.match(line):
            cur.counts["list items"] += 1
            cur.blocks.append("item")
            in_para = False
            continue
  • Files reviewed: 11/11 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread tools/check-parity.py
Comment thread tools/check-parity.py
S7 said the terminology check is clean when only the dead-row report remains, and no such section existed. The check's report gains a Dead glossary rows block, and S7 names it.
… list depth

HTML table rows and cells count with the Markdown ones. HTML headings start sections. When a tag opened on an earlier line closes, the rest of that line is scanned for components. List items carry their nesting depth and whether they are ordered, so a flattened or re-marked list reports.
Lists written as HTML inside table cells were invisible, so a dropped item passed. An li tag anywhere outside a code fence counts as a list item.
Copilot AI review requested due to automatic review settings September 11, 2026 15:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The parity checker has five unresolved moderate findings affecting baseline matching and equivalent Markdown/MDX handling.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (4)

Previously missed (1) — in code that hasn't changed since the last review.

tools/check-parity.py:155

  • An indented continuation of a Markdown list item is treated as a new paragraph here: - label\n description produces one list item plus one paragraph, while - label description produces only the list item. A translation can reflow the same list item this way, so the checker reports a parity mismatch for equivalent structure. Keep continuation lines associated with the list item and only count a nested paragraph when the Markdown actually separates it.

tools/check-parity.py:194

  • With --base, this comparison is positional, but where comes from the current English section list. If a change inserts or removes a heading before an existing drift, the unchanged mismatch is emitted under a different section label (and may be compared against a different twin section), so Counter(before) cannot subtract it and pre-existing drift is reported as new. Align sections or normalize mismatch identities before subtracting the base results.
        for i, (a, b) in enumerate(zip(en, tw)):
            where = names[i]

tools/check-parity.py:93

  • When a multiline JSX opening tag closes with another component on the same line (for example, ><Tabs>), this branch adds the nested name to components but never records its block. If the translated page puts <Tabs> on its own line, the component and count data are identical but blocks differ, so equivalent MDX formatting is reported as parity drift. Parse the remainder through the normal component path or make block comparison insensitive to this wrapping.
            cur.anchors.extend(TAG_ID_RE.findall(line))
            if ">" in line:
                in_tag = False
                cur.components.extend(INLINE_TAG_RE.findall(line.split(">", 1)[1]))
            continue

tools/check-parity.py:303

  • With --base, before and now are compared as rendered strings, but each string embeds where from labels_for() using the English heading text. Renaming an English heading while leaving an existing zh/ja mismatch unchanged changes that label between refs, so the Counter does not match it and reports it as a new issue. Compare structured mismatch keys (or resolve section identity independently of heading text) before formatting the message.
            before = issues_for(en, lambda rel: read_at(args.base, rel)) or []
            seen = Counter(before)
            for line in now:
  • Files reviewed: 11/11 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread tools/check-parity.py Outdated
The generic id scan ran before the heading branch, so an id on an HTML heading landed on the section above it and a divergence there was reported in the wrong place or missed. Heading ids, HTML or Markdown, now belong to the section the heading opens.
Copilot AI review requested due to automatic review settings September 11, 2026 16:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved critical and moderate findings affect parity correctness, performance, and review guidance.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (5)

Previously missed (1) — in code that hasn't changed since the last review.

tools/check-parity.py:150

  • Deriving the nesting marker with indent // 2 makes valid translated list indentation compare as different levels. The English metadata page uses three spaces for a nested marker while the Japanese twin uses four, although both represent the same nesting; this produces item@1 versus item@2 and a false block-order mismatch. Normalize the indentation to a stable nesting level before comparing it.

.claude/skills/dify-docs-editor-test/SKILL.md:36

  • When {SHAPE} is a file path, as the procedure specifies for references/spec-conventions.md, there is no named section to read, but this prompt still says to "read only that section." A judge can therefore skip the file's conventions and misclassify required structure. Distinguish the two cases by saying to read the file, or only the named section when one is provided.
   read only that section. A structure the conventions require is not a

tools/check-parity.py:226

  • With --all --base, each page causes three separate git show subprocesses for the English file and its two twins. On the 291-page corpus described in the PR, one full audit therefore starts about 873 Git processes, making the advertised corpus mode unnecessarily slow. Load the base tree or object contents once and serve path reads from an in-memory cache (or a batch Git reader).
def read_at(ref: str, rel: str) -> str | None:
    proc = subprocess.run(
        ["git", "-C", str(REPO), "show", f"{ref}:{rel}"], capture_output=True, text=True, encoding="utf-8"
    )
    return proc.stdout if proc.returncode == 0 else None

tools/check-parity.py:48

  • This regex records an HTML <table> wrapper as a component, while Markdown tables are normalized only as rows and cells. For example, en/cloud/use-dify/knowledge/metadata.mdx uses a Markdown table while its ja twin uses an equivalent <table> with the same rows and cells, so this reports a spurious components table, en none mismatch and can block otherwise equivalent new pages. Normalize both representations as a table block (or stop treating the wrapper as a component).
HTML_BLOCK_RE = re.compile(r"^\s*<(video|img|iframe|p|div|table|ul|ol|details|summary)\b")

tools/check-parity.py:308

  • Base filtering compares complete diagnostic strings, including the section label derived from the current English heading. If an English heading is renamed—while heading text is intentionally ignored for parity—an existing mismatch changes from section "Old" to section "New", so Counter(before) cannot subtract it and the checker reports it as newly introduced. Compare a stable issue key (path, section identity/occurrence, and mismatch kind) and format the label only after subtraction.
            seen = Counter(before)
            for line in now:
                if seen[line]:
                    seen[line] -= 1
                    old.append(line)
  • Files reviewed: 11/11 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread tools/check-parity.py
A mistyped --repo made every page read as deleted and the run printed PARITY OK. The path must hold an en/ directory, or the run exits 2.
Copilot AI review requested due to automatic review settings September 11, 2026 16:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved parity-checker correctness issues need to be addressed before approval.

Review details

Suppressed comments (7)

.claude/skills/dify-docs-write/SKILL.md:82

  • The documented exception is undefined: dify-docs-terminology-check reports General Terms, UI Labels, Stale Screenshots, and Glossary Gaps, but has no Dead glossary rows section or classification. As written, an agent cannot determine which findings may remain and may incorrectly treat real terminology findings as clean; either define that report category in the terminology skill or remove this exception.
2. Run `dify-docs-format-check`, then `dify-docs-terminology-check`, then the pack's S7 verifiers, fixing and re-running until each is clean; the terminology check is clean when its only remaining findings sit under its report's Dead glossary rows. A clean run is not a finished page.

tools/check-parity.py:313

  • The base subtraction keys an issue by its fully rendered text, including the current English section label from labels_for(). If a PR renames or inserts a heading while leaving an existing drift unchanged, the base issue has a different label/index and seen cannot match it, so the unchanged drift is falsely reported as new despite the documented --base behavior. Compare a heading-stable issue key (for example, section index plus mismatch kind) before formatting the message.
            seen = Counter(before)
            for line in now:
                if seen[line]:
                    seen[line] -= 1
                    old.append(line)
                else:
                    new.append(line)

tools/check-parity.py:123

  • This only counts HTML list items as an aggregate and does not preserve list kind or nesting. In particular, list markup inside a table row is handled before the component branch, so changing <ul> to <ol> or changing nested items into siblings can leave the counts, components, and block sequence identical and pass parity even though the rendered structure changed. Record the HTML list structure/depth as the Markdown list branch does.
        cur.counts["list items"] += len(HTML_ITEM_RE.findall(line))
        if TABLE_ROW_RE.match(line):
            if not TABLE_SEP_RE.match(line):
                cur.counts["table rows"] += 1
                cur.counts["table cells"] += len(line.strip().strip("|").split("|"))
                cur.blocks.append("row")
            cur.components.extend(INLINE_TAG_RE.findall(line))
            in_para = False

tools/check-parity.py:98

  • The prose says only the top-of-page disclaimer is skipped, but this condition drops any line anywhere in a page containing the hard-coded phrase. A legitimate later paragraph that quotes or explains the disclaimer would therefore be omitted from the section counts and could hide a real zh/ja-only addition. Restrict the exemption to the initial disclaimer block (and its expected blockquote form) rather than matching the phrase globally.
        if any(m in line for m in DISCLAIMER) or COMMENT_RE.match(line):
            continue

tools/check-parity.py:54

  • INLINE_TAG_RE scans raw Markdown, so it also treats angle-bracket placeholders inside inline code as components. For example, en/cli/reference/output-formats-and-exit-codes.mdx:48 contains request: <METHOD> <url>, which adds a fake METHOD component even though no MDX tag is rendered. A translated code example that uses a different placeholder then produces a false parity error; strip code spans before extracting tags or tokenize actual MDX/HTML tags.
INLINE_TAG_RE = re.compile(r"<([A-Z][A-Za-z]*)\b")

tools/check-parity.py:157

  • in_para is reset to False for every list item, so an indented continuation line without a blank line is counted as a new paragraph. In Markdown, a hard-wrapped continuation such as - text followed by more remains part of the same list-item paragraph; equivalent line wrapping between English and a translation will therefore be reported as parity drift. Track list-item paragraph state (or parse the Markdown AST) rather than treating every post-list indented line as a new paragraph.
        cur.anchors.extend(CUSTOM_ID_RE.findall(line))
        if not in_para:
            cur.counts["paragraphs"] += 1
            cur.blocks.append("para")
            in_para = True

tools/check-parity.py:81

  • This toggles on any line beginning with three backticks/tildes, regardless of the opening fence length. A valid four-backtick wrapper around a nested three-backtick example (the pattern used in writing-guides/formatting-guide.md) will therefore close the outer block at the inner fence, count its contents as page structure, and reopen at the inner closing fence. Track the fence character and length and only close on a matching fence of sufficient length.
        if FENCE_RE.match(line):
            if not in_fence:
                cur.counts["code blocks"] += 1
                cur.blocks.append("code")
            in_fence = not in_fence
  • Files reviewed: 11/11 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

…ity-check

# Conflicts:
#	writing-guides/index.md
Copilot AI review requested due to automatic review settings September 11, 2026 17:30
@RiskeyL
RiskeyL merged commit 897c9dd into main Sep 11, 2026
2 checks passed
@RiskeyL
RiskeyL deleted the chore/translation-parity-check branch September 11, 2026 17:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Multiple moderate correctness and portability issues remain in the parity checker.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (7)

tools/check-parity.py:145

  • Lines beginning with an unrecognized lowercase HTML tag are discarded here. For example, a translation-only paragraph rendered as <span>...</span> or <a href=...>...</a> contributes neither a paragraph nor a component (INLINE_TAG_RE only matches uppercase tags and the block allowlist omits these tags), so the parity check can pass despite extra content. Parse inline/raw HTML as paragraph content, or explicitly classify these tags without dropping their text.
        if line.lstrip().startswith("<"):
            in_para = False
            continue

tools/check-parity.py:173

  • The --base subtraction uses the rendered mismatch strings as Counter keys, but those strings include this title. Renaming an English heading (which the checker intentionally does not compare) changes section "Old" to section "New" and turns an unchanged pre-existing mismatch into a new failure; inserting a duplicate heading shifts the counters similarly. Keep a stable section/mismatch key separate from the display label when comparing refs.
        title = re.sub(r"\s+", " ", CUSTOM_ID_RE.sub("", re.sub(r"<[^>]*>", "", sec.title))).strip()[:60]
        seen[title] += 1
        out.append(f'section "{title}"' + (f" ({seen[title]})" if seen[title] > 1 else ""))

tools/check-parity.py:245

  • rel comes from str(Path.relative_to(...)), which uses backslashes on Windows, but this function only splits on /. On Windows every page is rejected in positional mode and --all finds zero pages, so the checker cannot run. Normalize the path with Path.parts (or otherwise accept both separators) before mapping the language root.
def to_en(rel: str) -> str | None:
    parts = rel.split("/", 1)
    if len(parts) != 2 or parts[0] not in LANGS:
        return None
    return f"en/{parts[1]}"

tools/check-parity.py:54

  • The component signature records only opening tags: INLINE_TAG_RE excludes </...> and the parser never appends closing tags. Consequently a translation with a missing or extra </Tab>/</Info> can have an identical component sequence and pass even though the MDX nesting/rendering is broken. Include closing/self-closing tags in the structural signature or validate tag balance.
INLINE_TAG_RE = re.compile(r"<([A-Z][A-Za-z]*)\b")

tools/check-parity.py:120

  • Only the aggregate number of cells is recorded for Markdown tables, not the cell count for each row. For example, English rows shaped 3+3 and a translation shaped 2+4 both produce two row blocks and six cells, so a column merge/split can pass PARITY OK even though the table structure no longer mirrors the source. Preserve and compare the per-row cell-count sequence.
                cur.counts["table cells"] += len(line.strip().strip("|").split("|"))

tools/check-parity.py:127

  • The raw-HTML table path has the same aggregate-cell blind spot: rows with different per-row <td>/<th> counts can have identical total rows and total cells and therefore pass parity. Record each <tr>'s cell shape (rather than only incrementing section totals) so merged or split cells are detected.
            cur.counts["table rows"] += len(HTML_ROW_RE.findall(line))
            cur.counts["table cells"] += len(HTML_CELL_RE.findall(line))

tools/check-parity.py:122

  • Because the Markdown-table branch returns before HTML_BLOCK_RE is evaluated, HTML_BLOCK_RE tags inside table cells are never included in components; INLINE_TAG_RE only captures capitalized JSX names. The repository uses tags such as <div> in Markdown table cells, so adding or removing one in a translation while keeping row/cell counts unchanged is invisible to this check. Extract the supported HTML block tags from table rows as well.
            cur.components.extend(INLINE_TAG_RE.findall(line))
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread tools/check-parity.py
LIST_MARK_RE = re.compile(r"^(\s*)(?:>\s*)*([-*+]|\d+[.)])\s+")
INLINE_TAG_RE = re.compile(r"<([A-Z][A-Za-z]*)\b")
COMMENT_RE = re.compile(r"^\s*\{/\*.*\*/\}\s*$")
FENCE_RE = re.compile(r"^\s*(```|~~~)")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants