All files / src markdown.ts

98.47% Statements 129/131
97.8% Branches 89/91
100% Functions 35/35
100% Lines 115/115

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),
      ]),
    ]),
  );
}