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 323 324 325 326 327 328 | 35x 395x 416x 395x 395x 648x 339x 395x 189x 210x 189x 350x 194x 488x 536x 488x 553x 488x 536x 569x 488x 488x 488x 488x 488x 1617x 23x 1617x 13x 1617x 1617x 340x 1277x 417x 417x 417x 417x 860x 427x 3x 3x 424x 424x 424x 433x 5x 428x 488x 133x 133x 133x 133x 533x 533x 533x 144x 533x 302x 533x 144x 389x 144x 144x 144x 245x 261x 245x 140x 140x 140x 140x 533x 428x 133x 1205x 1205x 4144x 3153x 3153x 3875x 3875x 3875x 3875x 3875x 3875x 3875x 3875x 3865x 3865x 622x 7092x 1617x 1617x 888x 1617x 428x 1617x 416x 394x 1223x 450x 403x 820x 412x 399x 374x 1617x 34x 20x 9x 2029x 883x 952x 970x 986x | import { canonicalId } from "./id.js";
import type { MouraManifest } from "./manifest.js";
import type { Heading as MdastHeading, Root } from "mdast";
import remarkParse from "remark-parse";
import { unified } from "unified";
import {
error,
type ValidationError,
type ValidationResult,
} from "./validation.js";
export interface MarkdownRequirement {
readonly id: string;
readonly scenarios: readonly MarkdownScenario[];
}
export interface MarkdownScenario {
readonly id: string;
readonly cases: readonly string[];
}
export interface MarkdownDocument {
readonly requirements: readonly MarkdownRequirement[];
}
/** Presentation metadata for a Requirement declaration; it is not identity. */
export interface RequirementSourceLocation {
readonly source: string;
readonly anchor: string;
readonly line: number;
readonly title?: string;
}
interface Heading {
readonly depth: number;
readonly token: string;
readonly title: string;
readonly line: number;
}
const RESERVED_ID_PREFIXES = ["REQ-", "SCN-", "CASE-"] as const;
export function parseRequirementMarkdown(
text: string,
source: string,
manifest: MouraManifest,
): ValidationResult<readonly string[]> {
const requirementIds = new Set(
manifest.requirements.map((item) => item.localId),
);
const managedIds = manifestManagedIds(manifest);
const requirements = headings(text)
.filter(
({ token }) =>
requirementIds.has(token) ||
(hasReservedIdPrefix(token) && !managedIds.has(token)),
)
.map(({ token }) => token);
return { value: requirements, errors: [] };
}
export function locateRequirementMarkdown(
text: string,
source: string,
manifest: MouraManifest,
): ReadonlyMap<string, RequirementSourceLocation> {
const requirementIds = new Set(
manifest.requirements.map((item) => item.localId),
);
return new Map(
headings(text)
.filter(({ token }) => requirementIds.has(token))
.map(({ token, line, title }) => [
token,
{ source, anchor: requirementAnchor(token), line, title },
]),
);
}
export function parseSpecificationMarkdown(
text: string,
source: string,
manifest: MouraManifest,
): ValidationResult<MarkdownDocument> {
const errors: ValidationError[] = [];
const reqIds = new Set(manifest.requirements.map((node) => node.localId));
const scnIds = new Set(
manifest.requirements.flatMap((node) =>
node.scenarios.map((child) => child.localId),
),
);
const caseIds = new Set(
manifest.requirements.flatMap((node) =>
node.scenarios.flatMap((child) =>
child.cases.map((leaf) => leaf.localId),
),
),
);
const declaredIds = manifestManagedIds(manifest);
const result: { id: string; scenarios: { id: string; cases: string[] }[] }[] =
[];
let currentRequirement: (typeof result)[number] | undefined;
let requirementDepth = 0;
let currentScenario: (typeof result)[number]["scenarios"][number] | undefined;
let scenarioDepth = 0;
for (const heading of headings(text)) {
if (currentScenario && heading.depth <= scenarioDepth)
currentScenario = undefined;
if (currentRequirement && heading.depth <= requirementDepth)
currentRequirement = undefined;
const kind = classify(
heading,
currentRequirement,
currentScenario,
manifest,
reqIds,
scnIds,
caseIds,
declaredIds,
requirementDepth,
scenarioDepth,
);
if (!kind) {
continue;
}
if (kind === "requirement") {
currentRequirement = { id: heading.token, scenarios: [] };
result.push(currentRequirement);
requirementDepth = heading.depth;
currentScenario = undefined;
} else if (kind === "scenario") {
if (!currentRequirement || heading.depth <= requirementDepth) {
errors.push(
error(
"invalid-markdown-hierarchy",
`${heading.token} is not beneath a Requirement heading in ${source}`,
{ source },
),
);
currentScenario = undefined;
} else {
currentScenario = { id: heading.token, cases: [] };
currentRequirement.scenarios.push(currentScenario);
scenarioDepth = heading.depth;
}
} else if (
!currentRequirement ||
!currentScenario ||
heading.depth <= scenarioDepth
) {
errors.push(
error(
"invalid-markdown-hierarchy",
`${heading.token} is not beneath a Scenario heading in ${source}`,
{ source },
),
);
} else {
currentScenario.cases.push(heading.token);
}
}
return { value: { requirements: result }, errors };
}
/** Presentation locations use the same scoped IDs as the canonical manifest. */
export function locateSpecificationMarkdown(
text: string,
source: string,
manifest: MouraManifest,
): ReadonlyMap<string, RequirementSourceLocation> {
const locations = new Map<string, RequirementSourceLocation>();
let requirement: MouraManifest["requirements"][number] | undefined;
let scenario:
NonNullable<typeof requirement>["scenarios"][number] | undefined;
let requirementDepth = 0;
let scenarioDepth = 0;
for (const heading of headings(text)) {
if (heading.depth <= scenarioDepth) scenario = undefined;
if (heading.depth <= requirementDepth) requirement = undefined;
const testCase = scenario?.cases.find(
(item) => item.localId === heading.token,
);
const nextScenario = requirement?.scenarios.find(
(item) => item.localId === heading.token,
);
let id: string | undefined;
if (testCase && requirement && scenario) {
id = canonicalId([requirement, scenario, testCase]);
} else if (nextScenario && requirement) {
scenario = nextScenario;
scenarioDepth = heading.depth;
id = canonicalId([requirement, scenario]);
} else {
const nextRequirement = manifest.requirements.find(
(item) => item.localId === heading.token,
);
if (nextRequirement) {
requirement = nextRequirement;
requirementDepth = heading.depth;
scenario = undefined;
id = requirement.localId;
}
}
if (id)
locations.set(id, {
source,
line: heading.line,
title: heading.title,
anchor: requirementAnchor(id),
});
}
return locations;
}
function headings(text: string): Heading[] {
const tree = unified().use(remarkParse).parse(text) as Root;
// Moura structure is expressed only by document-level headings. A heading
// nested in a block quote, list, or another container is content belonging
// to that container, not an active Requirement, Scenario, or Case.
return tree.children.flatMap((node) => {
if (node.type !== "heading") return [];
const heading = atxHeading(node, text);
return heading ? [heading] : [];
});
}
/** Read the raw declaration token of an ATX heading without an ID alphabet. */
export function atxHeading(
node: MdastHeading,
markdown: string,
): Heading | undefined {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
Iif (start === undefined || end === undefined) return undefined;
// Use the original source for Moura's token rather than imposing an ID
// alphabet here. remark-parse remains responsible for Markdown syntax.
const source = markdown.slice(start, end);
const marker = new RegExp(`^#{${node.depth}}(?:[\\t ]+|$)`, "u").exec(source);
Iif (!marker) return undefined; // Excludes Setext headings.
const token = /^[\p{White_Space}]*([^\p{White_Space}]+)/u.exec(
source.slice(marker[0].length),
)?.[1];
if (!token) return undefined;
const title = source
.slice(marker[0].length)
.trimStart()
.slice(token.length)
.replace(/[\t ]+#+[\t ]*$/u, "")
.trim();
return { depth: node.depth, token, title, line: node.position!.start.line };
}
function requirementAnchor(value: string): string {
return `requirement-${Array.from(value, (character) =>
character.codePointAt(0)!.toString(16),
).join("-")}`;
}
function classify(
heading: Heading,
requirement: MarkdownRequirement | undefined,
scenario: MarkdownScenario | undefined,
manifest: MouraManifest,
requirements: Set<string>,
scenarios: Set<string>,
cases: Set<string>,
declaredIds: Set<string>,
requirementDepth: number,
scenarioDepth: number,
): "requirement" | "scenario" | "case" | undefined {
const { depth, token } = heading;
const manifestRequirement = requirement
? manifest.requirements.find((item) => item.localId === requirement.id)
: undefined;
const manifestScenario = scenario
? manifestRequirement?.scenarios.find(
(item) => item.localId === scenario.id,
)
: undefined;
// The active ancestors and relative heading depth disambiguate scoped local
// IDs. Prefixes are only used to notice an undeclared managed token below.
if (
scenario &&
depth > scenarioDepth &&
manifestScenario?.cases.some((item) => item.localId === token)
)
return "case";
if (
requirement &&
depth > requirementDepth &&
manifestRequirement?.scenarios.some((item) => item.localId === token)
)
return "scenario";
if (requirements.has(token)) return "requirement";
// A declared ID that has only one possible kind remains identifiable when
// its expected parent is missing, so the caller can report bad hierarchy.
if (scenarios.has(token) && !cases.has(token)) return "scenario";
if (cases.has(token) && !scenarios.has(token)) return "case";
const managed = declaredIds.has(token) || hasReservedIdPrefix(token);
if (!managed) return undefined;
if (scenario && depth > scenarioDepth) return "case";
if (requirement && depth > requirementDepth) return "scenario";
return "requirement";
}
function hasReservedIdPrefix(token: string): boolean {
return RESERVED_ID_PREFIXES.some((prefix) => token.startsWith(prefix));
}
/**
* Classifies whether a token is managed by the manifest, independently of the
* source role and hierarchy checks that determine where that node may occur.
*/
function manifestManagedIds(manifest: MouraManifest): Set<string> {
return new Set(
manifest.requirements.flatMap((requirement) => [
requirement.localId,
...requirement.scenarios.flatMap((scenario) => [
scenario.localId,
...scenario.cases.map((testCase) => testCase.localId),
]),
]),
);
}
|