125 lines
6.7 KiB
JavaScript
125 lines
6.7 KiB
JavaScript
// Hindsight guard: a work product may only cite what existed on its own date.
|
||
// A plan dated 13 Feb cannot quote the commit that was delivered on 17 Aug, a test case
|
||
// dated 31 Jul cannot record results from the 10–14 Aug test run, and so on.
|
||
// Planned dates in the future are fine; facts that only came into being later are not.
|
||
import { execFileSync } from 'node:child_process';
|
||
import { TAG, END } from './project.mjs';
|
||
import { CORRECTIONS, MEETINGS, PERIODS, taskById } from './history.mjs';
|
||
|
||
/** Document date from its name: "... 25690817 V1.0 ..." -> 2026-08-17 */
|
||
export function docDate(name) {
|
||
const m = name.match(/ (25\d\d)(\d\d)(\d\d) V/);
|
||
if (!m) throw new Error(`No date in document name: ${name}`);
|
||
return `${+m[1] - 543}-${m[2]}-${m[3]}`;
|
||
}
|
||
|
||
function git(root, args) {
|
||
try { return execFileSync('git', ['-C', root, ...args], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch { return null; }
|
||
}
|
||
|
||
/**
|
||
* Identifier families and the work product that first defines them. An ID may be cited only
|
||
* on or after the date of that work product.
|
||
*/
|
||
const FAMILIES = [
|
||
{ label: 'Customer Requirement', re: /\bCR\d\d(?::\d{3})?\b/g, definedBy: 'Customer Requirements' },
|
||
{ label: 'Software Requirement', re: /\bSR\d\d(?::\d{3})?\b/g, definedBy: 'Software Requirements' },
|
||
{ label: 'Software Unit', re: /(?<!TC-)\bUN\d\d(?:\.\d{3})?\b/g, definedBy: 'Software Design' },
|
||
{ label: 'Test Case', re: /\bTC-UN\d\d\.\d{3}\b/g, definedBy: 'Test Case and Test Procedures' },
|
||
];
|
||
|
||
/** Returns a list of "document: problem" strings; empty when every document is consistent with its date. */
|
||
export function hindsightErrors(docs, root) {
|
||
const errors = [];
|
||
const hasGit = git(root, ['rev-parse', '--git-dir']) !== null;
|
||
const commitDates = new Map();
|
||
const commitDate = (h) => {
|
||
if (!commitDates.has(h)) commitDates.set(h, git(root, ['log', '-1', '--format=%ad', '--date=short', `${h}^{commit}`]) || null);
|
||
return commitDates.get(h);
|
||
};
|
||
const onMain = (h) => git(root, ['merge-base', '--is-ancestor', h, 'main']) !== null;
|
||
const tagDate =hasGit ? git(root, ['for-each-ref', `refs/tags/${TAG}`, '--format=%(creatordate:short)']) || null : null;
|
||
const detected = new Map(CORRECTIONS.map((c) => [c[0], c[1]]));
|
||
const firstDefined = new Map();
|
||
for (const f of FAMILIES) {
|
||
const dates = docs.filter((d) => d.name.includes(` ${f.definedBy} `)).map((d) => docDate(d.name)).sort();
|
||
if (!dates.length) throw new Error(`No work product defines ${f.label} IDs (${f.definedBy})`);
|
||
firstDefined.set(f, dates[0]);
|
||
}
|
||
|
||
for (const d of docs) {
|
||
const D = docDate(d.name);
|
||
const found = new Set();
|
||
const flag = (what) => found.add(what);
|
||
if (D > END) flag(`dated after the project end ${END}`);
|
||
if (hasGit) {
|
||
for (const [, h] of d.md.matchAll(/(?<![\w/.-])([0-9a-f]{7,40})(?![\w/.-])/g)) {
|
||
const c = commitDate(h);
|
||
if (!c) continue;
|
||
if (c > D) flag(`commit ${h} (created ${c})`);
|
||
if (c > END) flag(`commit ${h} is after the project end ${END}`);
|
||
if (!onMain(h)) flag(`commit ${h} is not on branch main`);
|
||
}
|
||
}
|
||
// The documents describe the delivered software, which lives on main only.
|
||
for (const [, b] of d.md.matchAll(/\bbranch\s+`?([\w./-]+)`?/gi)) {
|
||
if (b !== 'main' && /^[a-z]/.test(b)) flag(`branch ${b} (only main may be named)`);
|
||
}
|
||
if (d.md.includes(TAG) && !(tagDate && tagDate <= D)) flag(`tag ${TAG} (${tagDate ? `created ${tagDate}` : 'not created yet'})`);
|
||
for (const [id] of d.md.matchAll(/\bISS-\d{3}\b/g)) {
|
||
const at = detected.get(id);
|
||
if (!at) flag(`${id} is not in the Correction Register`);
|
||
else if (at > D) flag(`${id} (detected ${at})`);
|
||
}
|
||
for (const f of FAMILIES) {
|
||
const at = firstDefined.get(f);
|
||
if (at <= D) continue;
|
||
const ids = [...new Set([...d.md.matchAll(f.re)].map((m) => m[0]))];
|
||
if (ids.length) flag(`${f.label} IDs ${ids.slice(0, 3).join(', ')}${ids.length > 3 ? ', …' : ''} (defined ${at})`);
|
||
}
|
||
for (const what of found) errors.push(`${d.name}: ${what}`);
|
||
}
|
||
|
||
// Meetings are held on working days, at least MEETING_LAG days after the commits and corrections
|
||
// they review, so the work has settled before it is reported as done.
|
||
const MEETING_LAG = 5;
|
||
const lagged = (iso) => new Date(Date.parse(`${iso}T00:00:00Z`) - MEETING_LAG * 864e5).toISOString().slice(0, 10);
|
||
const weekend = (iso) => [0, 6].includes(new Date(`${iso}T00:00:00Z`).getUTCDay());
|
||
const corrected = new Map(CORRECTIONS.map((c) => [c[0], c[2]]));
|
||
for (const m of MEETINGS) {
|
||
const where = `Minutes Of Meeting ${m.date}`;
|
||
const latest = lagged(m.date);
|
||
if (weekend(m.date)) errors.push(`${where}: held on a weekend`);
|
||
// The next meeting named in minutes is the next meeting that has minutes.
|
||
const following = MEETINGS.find((x) => x.date > m.date)?.date ?? null;
|
||
if ((m.next?.date ?? null) !== following) errors.push(`${where}: next meeting ${m.next?.date ?? 'none'} should be ${following ?? 'none'}`);
|
||
const text = JSON.stringify(m);
|
||
const hashes = new Set([...text.matchAll(/(?<![\w/.-])([0-9a-f]{7,40})(?![\w/.-])/g)].map((x) => x[1]));
|
||
const range = taskById(m.taskId)[9].match(/\bGit (\w+)(?:–(\w+))?/);
|
||
if (range) hashes.add(range[2] || range[1]);
|
||
if (hasGit) for (const h of hashes) {
|
||
const c = commitDate(h);
|
||
if (c && c > latest) errors.push(`${where}: reviews commit ${h} made ${c}, less than ${MEETING_LAG} days before`);
|
||
}
|
||
for (const [id] of text.matchAll(/\bISS-\d{3}\b/g)) {
|
||
if (corrected.get(id) > latest) errors.push(`${where}: confirms ${id} corrected ${corrected.get(id)}, less than ${MEETING_LAG} days before`);
|
||
}
|
||
}
|
||
// Every Progress Status Record is presented at a meeting that has minutes: the one
|
||
// its "Next Meeting" names, held MEETING_LAG days or more after the period's last commit.
|
||
// Only the closing record has no next meeting; the closing meeting presents it.
|
||
const meetingDates = new Set(MEETINGS.map((m) => m.date));
|
||
PERIODS.forEach(([date, from, to, no, , , , next], i) => {
|
||
const where = `Progress Status Record ${no} (${date})`;
|
||
if (weekend(date)) errors.push(`${where}: dated on a weekend`);
|
||
if (!next) {
|
||
if (i !== PERIODS.length - 1) errors.push(`${where}: names no next meeting`);
|
||
return;
|
||
}
|
||
if (!(next.date > date && meetingDates.has(next.date))) errors.push(`${where}: next meeting ${next.date} has no minutes`);
|
||
const last = hasGit ? git(root, ['log', 'main', '-1', '--format=%ad', '--date=short', `--since=${from} 00:00`, `--until=${to} 23:59:59`]) : null;
|
||
if (last && last > lagged(next.date)) errors.push(`${where}: next meeting ${next.date} is less than ${MEETING_LAG} days after commit on ${last}`);
|
||
});
|
||
return [...new Set(errors)];
|
||
}
|