checkQuestionRegister function

RegisterReport checkQuestionRegister(
  1. String blueprintYaml,
  2. String clarifications, {
  3. QuestionRegisterConfig config = QuestionRegisterConfig.defaults,
})

Checks clarifications (the index file's text) against the question blocks of blueprintYaml, by the project's config (default: QuestionRegisterConfig.defaults).

Implementation

RegisterReport checkQuestionRegister(
  String blueprintYaml,
  String clarifications, {
  QuestionRegisterConfig config = QuestionRegisterConfig.defaults,
}) {
  final report = RegisterReport();
  final idOrder = _idOrder(config.prefixes);

  // --- blocks in the Blueprint --------------------------------------------
  final annotations = SpecReviewAnnotations(
    SpecDocument()..setContent('blueprint', blueprintYaml),
  );
  for (final e in annotations.validate()) {
    if (e.code == SpecValidationCode.annotationMalformed &&
        e.message.contains('question header')) {
      report.findings.add('malformed question block: ${e.message}');
    }
  }
  final blocks = <String, QuestionBlock>{};
  for (final a in annotations.list()) {
    final id = a.id;
    if (a.kind != SpecReviewAnnotationKind.question ||
        !config.isQuestionId(id!)) {
      continue;
    }
    if (blocks.containsKey(id)) {
      report.findings.add(
        'duplicate block: $id at lines ${blocks[id]!.line} and ${a.line + 1}',
      );
    }
    blocks[id] = QuestionBlock(
      id,
      a.line + 1,
      answered: a.answer.isNotEmpty && !a.answer.startsWith('see '),
    );
  }
  report.blocks = blocks.length;
  for (final b in blocks.values) {
    if (b.answered) report.answeredNotApplied.add('${b.id} (line ${b.line})');
  }

  // --- the index ---------------------------------------------------------
  String section(String from, String to) {
    final a = clarifications.indexOf(from);
    final b = clarifications.indexOf(to, a < 0 ? 0 : a);
    if (a < 0) return '';
    return clarifications.substring(a, b < 0 ? clarifications.length : b);
  }

  final index = <String, IndexRow>{};
  final order = <String>[];
  for (final line in section(
    '## Index',
    '## Not in the Blueprint',
  ).split('\n')) {
    if (config.rowId(line) == null) continue;
    final cells = line.trim().split(_pipe).map((c) => c.trim()).toList();
    // A row is `| id | ★ | where | topic | status | note |`: seven pieces
    // around the six cells, the first and last empty.
    if (cells.length != 8 || cells.first.isNotEmpty || cells.last.isNotEmpty) {
      report.findings.add(
        'malformed index row: ${line.length > 80 ? line.substring(0, 80) : line}…',
      );
      continue;
    }
    final row = IndexRow(
      cells[1],
      cells[2],
      cells[3],
      cells[4],
      cells[5],
      cells[6],
    );
    if (index.containsKey(row.id)) {
      report.findings.add('index row twice: ${row.id}');
    }
    final known = config.statuses.any(
      (st) => st.key == 'duplicate'
          ? (row.status.startsWith('${st.label} → ') &&
                config.isQuestionId(row.status.substring(st.label.length + 3)))
          : row.status == st.label,
    );
    if (!known) {
      report.findings.add('${row.id}: unknown status "${row.status}"');
    }
    index[row.id] = row;
    order.add(row.id);
  }
  for (var i = 1; i < order.length; i++) {
    if (idOrder(order[i - 1], order[i]) > 0) {
      report.findings.add(
        'index out of order: ${order[i - 1]} before ${order[i]}',
      );
    }
  }
  report.rows = index.length;

  final notInBlueprint = <String, String>{};
  for (final line in section('## Not in the Blueprint', '\n## ').split('\n')) {
    if (config.rowId(line) == null) continue;
    final cells = line.trim().split(_pipe).map((c) => c.trim()).toList();
    notInBlueprint[cells[1]] = cells.length > 2 ? cells[2] : '';
  }

  // --- the two against each other -----------------------------------------
  for (final id in blocks.keys) {
    if (!index.containsKey(id)) {
      report.findings.add('$id: block in the Blueprint, no index row');
    }
  }
  index.forEach((id, row) {
    final inBlueprint = blocks.containsKey(id);
    final waiting = config.statuses.any(
      (st) => st.key == row.statusKey && st.waiting,
    );
    switch (waiting ? 'waiting' : row.statusKey) {
      case 'waiting':
        if (!inBlueprint) {
          report.findings.add(
            '$id: ${row.status} in the index, but no block in the Blueprint',
          );
        }
      case 'duplicate':
        if (!inBlueprint && notInBlueprint[id] != row.status) {
          report.findings.add(
            '$id: duplicate without a pointer block, and not under "Not in the Blueprint"',
          );
        }
      default:
        if (inBlueprint) {
          report.findings.add(
            '$id: ${row.status} in the index, but its block is still in the Blueprint',
          );
        }
        if (notInBlueprint[id] != row.status) {
          report.findings.add(
            '$id: ${row.status} in the index, but "Not in the Blueprint" '
            '${notInBlueprint.containsKey(id) ? 'says ${notInBlueprint[id]}' : 'lacks it'}',
          );
        }
    }
  });
  notInBlueprint.forEach((id, status) {
    if (!index.containsKey(id)) {
      report.findings.add(
        '$id: under "Not in the Blueprint" but not in the index',
      );
    }
  });

  // --- the counts ----------------------------------------------------------
  final maxOf = <String, int>{};
  for (final id in index.keys) {
    final dash = id.lastIndexOf('-');
    final prefix = id.substring(0, dash);
    final number = int.parse(id.substring(dash + 1));
    if (number > (maxOf[prefix] ?? 0)) maxOf[prefix] = number;
  }
  final groups = config.groups.isNotEmpty
      ? config.groups
      : [
          for (final prefix
              in (maxOf.keys.toList()..sort((x, y) => idOrder('$x-0', '$y-0'))))
            QuestionRegisterGroup(
              key: prefix,
              label: '$prefix-01 to $prefix-{max}',
              prefix: prefix,
            ),
        ];
  String labelOf(QuestionRegisterGroup g) =>
      g.label.replaceAll('{max}', '${maxOf[g.prefix] ?? 0}');
  final count = <String, int>{};
  final star = <String, int>{};
  for (final row in index.values) {
    final key = '${row.statusKey}/${config.groupOf(row.id)}';
    count[key] = (count[key] ?? 0) + 1;
    if (row.star.isNotEmpty) {
      star[row.statusKey] = (star[row.statusKey] ?? 0) + 1;
    }
  }
  int c(String s, String g) => count['$s/$g'] ?? 0;
  final header =
      '| Status | ${[for (final g in groups) labelOf(g)].join(' | ')} | All | ★ |';
  final table = <String>[
    header,
    '|--------|${[for (final _ in groups) '------:'].join('|')}|----:|--:|',
  ];
  final totals = List<int>.filled(groups.length, 0);
  var ts = 0;
  for (final st in config.statuses) {
    final cells = [for (final g in groups) c(st.key, g.key)];
    for (var i = 0; i < cells.length; i++) {
      totals[i] += cells[i];
    }
    ts += star[st.key] ?? 0;
    final starCell = st.waiting ? '${star[st.key] ?? 0}' : '—';
    table.add(
      '| ${st.label} | ${cells.join(' | ')} | ${cells.fold(0, (x, y) => x + y)} | $starCell |',
    );
  }
  table.add(
    '| **all** | ${[for (final t in totals) '**$t**'].join(' | ')} | '
    '**${totals.fold(0, (x, y) => x + y)}** | **$ts** |',
  );
  final waitingKeys = [
    for (final st in config.statuses)
      if (st.waiting) st.key,
  ];
  report.waiting = [
    for (final s in waitingKeys)
      for (final g in groups) c(s, g.key),
  ].fold(0, (a, b) => a + b);
  report.waitingStarred = waitingKeys.fold(0, (a, s) => a + (star[s] ?? 0));

  // Write the counts into the file, then compare with what it said.
  var out = clarifications;
  final tableStart = groups.isEmpty
      ? -1
      : out.indexOf('| Status | ${labelOf(groups.first).split('{max}').first}');
  if (tableStart < 0) {
    report.findings.add('the status table is missing');
  } else {
    var tableEnd = tableStart;
    for (var n = 0; n < table.length; n++) {
      tableEnd = out.indexOf('\n', tableEnd) + 1;
    }
    out = out.replaceRange(tableStart, tableEnd, '${table.join('\n')}\n');
  }
  out = out.replaceFirstMapped(
    RegExp(
      r'\d+ questions and proposals wait for an answer, \d+ of them marked ★;\n\d+ blocks are in the Blueprint',
    ),
    (_) =>
        '${report.waiting} questions and proposals wait for an answer, '
        '${report.waitingStarred} of them marked ★;\n${report.blocks} blocks are in the Blueprint',
  );
  maxOf.forEach((prefix, max) {
    out = out.replaceAll(
      RegExp(
        '${RegExp.escape(prefix)}-01 to ${RegExp.escape(prefix)}-\\d+\\*\\*',
      ),
      '$prefix-01 to $prefix-$max**',
    );
  });
  report.rewritten = out;
  if (out != clarifications) {
    report.findings.add(
      'the status counts, the sentence below them or the id range are out of date '
      '(run with --write)',
    );
  }
  return report;
}