Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 | 33x 180x 180x 180x 180x 180x 4x 180x 22x 180x 24x 180x 180x 180x 900x 51x 180x 360x 360x 6x 360x 360x 360x 360x 360x 360x 360x 3742x 651x 3742x 42x 3742x 296x 3742x 3742x 88x 3742x 32x 3742x 33x 360x 360x 360x 360x 360x 158x 202x 202x 129x 129x 134x 140x 360x 360x 1159x 722x 722x 503x 503x 503x 503x 503x 360x 1725x 1490x 3282x 3282x 1365x 2198x 1081x 1117x 1111x 1105x 2579x 2551x 3x 2548x 935x 1613x 1607x 1486x 6664x 3742x 3742x 1591x 1591x 3382x 3382x | import type { Root } from "mdast";
import remarkParse from "remark-parse";
import { unified } from "unified";
import type { MouraManifest } from "./manifest.js";
import {
atxHeading,
parseRequirementMarkdown,
parseSpecificationMarkdown,
type MarkdownDocument,
} from "./markdown.js";
export const GENERATED_VIEW_NOTICE = `> **Generated file — do not edit.** This Japanese view is derived from the corresponding English document in the repository root. The English document is authoritative.
>
> **生成ファイル — 編集しないでください。** この日本語版はリポジトリ直下の対応する英語文書から生成されています。英語文書が正本です。
`;
export type DocumentRole = "requirements" | "specifications";
interface ProtectedMarkdown {
readonly code: readonly string[];
readonly html: readonly string[];
readonly links: readonly string[];
readonly references: readonly string[];
readonly placements: readonly string[];
}
interface AstNode {
readonly type?: unknown;
readonly value?: unknown;
readonly alt?: unknown;
readonly url?: unknown;
readonly title?: unknown;
readonly lang?: unknown;
readonly meta?: unknown;
readonly identifier?: unknown;
readonly referenceType?: unknown;
readonly children?: unknown;
readonly depth?: unknown;
readonly ordered?: unknown;
readonly start?: unknown;
readonly spread?: unknown;
readonly checked?: unknown;
readonly position?: { readonly start: { readonly offset?: number } };
}
export function validateJapaneseView(
canonical: string,
generated: string,
role: DocumentRole,
manifest: MouraManifest,
): readonly string[] {
const problems: string[] = [];
const canonicalStructure = structure(canonical, role, "canonical", manifest);
const generatedStructure = structure(generated, role, "generated", manifest);
Iif (canonicalStructure.errors.length > 0)
problems.push(...canonicalStructure.errors);
if (generatedStructure.errors.length > 0)
problems.push(...generatedStructure.errors);
if (
JSON.stringify(canonicalStructure.value) !==
JSON.stringify(generatedStructure.value)
)
problems.push(`${role}: traceability identifiers or hierarchy changed`);
if (
JSON.stringify(
blockStructure(canonical, canonicalStructure.value, role),
) !==
JSON.stringify(
blockStructure(
withoutGeneratedNotice(generated),
generatedStructure.value,
role,
),
)
)
problems.push(`${role}: Markdown block structure changed`);
const canonicalProtected = protectedMarkdown(canonical);
const generatedProtected = protectedMarkdown(
withoutGeneratedNotice(generated),
);
for (const key of Object.keys(
canonicalProtected,
) as (keyof ProtectedMarkdown)[]) {
if (
JSON.stringify(canonicalProtected[key]) !==
JSON.stringify(generatedProtected[key])
)
problems.push(`${role}: protected Markdown ${key} changed`);
}
return problems;
}
function structure(
markdown: string,
role: DocumentRole,
source: string,
manifest: MouraManifest,
): { readonly value: unknown; readonly errors: readonly string[] } {
const parsed =
role === "requirements"
? parseRequirementMarkdown(markdown, source, manifest)
: parseSpecificationMarkdown(markdown, source, manifest);
return {
value: parsed.value,
errors: parsed.errors.map((item) => `${role}: ${item.message}`),
};
}
function protectedMarkdown(markdown: string): ProtectedMarkdown {
const tree = unified().use(remarkParse).parse(markdown) as Root;
const code: string[] = [];
const html: string[] = [];
const links: string[] = [];
const references: string[] = [];
const placements: string[] = [];
walk(tree as AstNode, (node, blockPath) => {
if (
[
"code",
"inlineCode",
"html",
"link",
"image",
"linkReference",
"imageReference",
"definition",
].includes(String(node.type))
)
placements.push(JSON.stringify([node.type, blockPath]));
if (node.type === "code")
code.push(
JSON.stringify([
blockPath,
node.type,
node.lang ?? null,
node.meta ?? null,
node.value,
]),
);
if (node.type === "inlineCode")
code.push(JSON.stringify([blockPath, node.type, node.value]));
if (node.type === "html") html.push(String(node.value));
if (node.type === "link" || node.type === "image")
links.push(JSON.stringify([node.type, node.url, node.title ?? null]));
if (node.type === "linkReference" || node.type === "imageReference")
references.push(
JSON.stringify([node.type, node.identifier, node.referenceType]),
);
if (node.type === "definition")
references.push(
JSON.stringify([
node.type,
node.identifier,
node.url,
node.title ?? null,
]),
);
});
// Japanese prose can reverse "A instead of B" to "B の代わりに A".
// Compare code values as a multiset tied to each containing block, not
// English word order. Duplicates remain significant; code contents (including
// command order) and fenced-code metadata remain exact.
return { code: code.sort(), html, links, references, placements };
}
function withoutGeneratedNotice(markdown: string): string {
return markdown.startsWith(GENERATED_VIEW_NOTICE)
? markdown.slice(GENERATED_VIEW_NOTICE.length)
: markdown;
}
/** Capture block topology and content presence, never translated prose identity. */
function blockStructure(
markdown: string,
traceability: unknown,
role: DocumentRole,
): unknown {
const tree = unified().use(remarkParse).parse(markdown) as Root;
const ids = new Set<string>();
if (role === "requirements") {
for (const id of traceability as readonly string[]) ids.add(id);
} else {
const document = traceability as MarkdownDocument;
for (const requirement of document.requirements) {
ids.add(requirement.id);
for (const scenario of requirement.scenarios) {
ids.add(scenario.id);
for (const id of scenario.cases) ids.add(id);
}
}
}
const headingProse = new Map<AstNode, boolean>();
for (const node of tree.children) {
if (node.type !== "heading") continue;
const heading = atxHeading(node, markdown);
if (!heading || !ids.has(heading.token)) continue;
const source = markdown.slice(
node.position!.start.offset!,
node.position!.end.offset!,
);
// The existing parser recognizes this document-level heading token.
// Remove only that declaration, never search ordinary prose for IDs.
const tokenStart = source.indexOf(heading.token, node.depth);
const withoutId =
source.slice(0, tokenStart) +
source.slice(tokenStart + heading.token.length);
const proseTree = unified().use(remarkParse).parse(withoutId) as Root;
headingProse.set(
node as AstNode,
hasProse(proseTree as AstNode, withoutId),
);
}
return blockChildren(tree as AstNode, headingProse, markdown);
}
function blockChildren(
node: AstNode,
headingProse: ReadonlyMap<AstNode, boolean>,
markdown: string,
): readonly unknown[] {
if (!Array.isArray(node.children)) return [];
return node.children.flatMap((child) => {
const block = child as AstNode;
if (isInlineNode(block.type)) return [];
return [
{
type: block.type,
...(block.type === "heading" || block.type === "paragraph"
? {
hasContent: hasContent(block),
hasProse: headingProse.get(block) ?? hasProse(block, markdown),
}
: {}),
...(block.type === "heading" ? { depth: block.depth } : {}),
...(block.type === "list"
? {
ordered: block.ordered,
start: block.start,
spread: block.spread,
}
: {}),
...(block.type === "listItem"
? { checked: block.checked, spread: block.spread }
: {}),
children: blockChildren(block, headingProse, markdown),
},
];
});
}
/** Non-whitespace text, code/HTML, or image alt text constitutes block content. */
function hasContent(node: AstNode): boolean {
if (typeof node.value === "string" && node.value.trim().length > 0)
return true;
if (typeof node.alt === "string" && node.alt.trim().length > 0) return true;
return (
Array.isArray(node.children) &&
node.children.some((child) => hasContent(child as AstNode))
);
}
/** Protected machine nodes cannot stand in for translatable prose. */
function hasProse(node: AstNode, markdown: string): boolean {
if (node.type === "inlineCode" || node.type === "html") return false;
// Autolink text is the protected URL itself, not a translatable label.
if (
node.type === "link" &&
markdown[node.position?.start.offset ?? -1] === "<"
)
return false;
if (node.type === "text")
return typeof node.value === "string" && node.value.trim().length > 0;
if (typeof node.alt === "string" && node.alt.trim().length > 0) return true;
return (
Array.isArray(node.children) &&
node.children.some((child) => hasProse(child as AstNode, markdown))
);
}
function isInlineNode(type: unknown): boolean {
return (
typeof type === "string" &&
[
"text",
"emphasis",
"strong",
"delete",
"inlineCode",
"break",
"link",
"image",
"linkReference",
"imageReference",
].includes(type)
);
}
/** Track containing blocks without making translated prose or inline formatting identity. */
function walk(
node: AstNode,
visit: (node: AstNode, blockPath: readonly number[]) => void,
blockPath: readonly number[] = [],
): void {
visit(node, blockPath);
if (!Array.isArray(node.children)) return;
let blockIndex = 0;
for (const child of node.children) {
const next = child as AstNode;
walk(
next,
visit,
isInlineNode(next.type) ? blockPath : [...blockPath, blockIndex++],
);
}
}
|