copperhead 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Fab release gate checks (OpenSpec: add-fab-release-gate).
3
+ * Documentation presence is pure over file contents: LLM-free, network-free.
4
+ */
5
+
6
+ /** Violation shape shared by the fab JSON report (`claim` / `actual` / optional location). */
7
+ export interface FabViolation {
8
+ claim: string;
9
+ actual: string;
10
+ location?: string;
11
+ }
12
+
13
+ export interface FabCheckResult {
14
+ status: 'pass' | 'warn' | 'fail';
15
+ violations: FabViolation[];
16
+ }
17
+
18
+ export const DRAFT_QUALITY_HEADING = '## Draft quality';
19
+
20
+ /** Config marker: repos produced by `copperhead create` set `origin` to `"create"`. */
21
+ export const CREATE_ORIGIN = 'create';
22
+
23
+ /**
24
+ * True when `.copperhead/config.json` marks the repo as create-produced.
25
+ * Accepts the raw parsed object (or a loaded config that retained `origin`).
26
+ */
27
+ export function isCreateProducedRepo(config: unknown): boolean {
28
+ if (config === null || typeof config !== 'object') return false;
29
+ return (config as { origin?: unknown }).origin === CREATE_ORIGIN;
30
+ }
31
+
32
+ /**
33
+ * Body of `## Draft quality` through the next `##` heading, or `null` if the
34
+ * heading is absent. HTML comments and whitespace alone do not count as filled
35
+ * (init scaffolds the empty heading + a placeholder comment).
36
+ */
37
+ export function draftQualitySection(layoutMd: string): string | null {
38
+ const lines = layoutMd.split(/\r?\n/);
39
+ let start = -1;
40
+ for (let i = 0; i < lines.length; i++) {
41
+ if (lines[i]!.trim() === DRAFT_QUALITY_HEADING) {
42
+ start = i + 1;
43
+ break;
44
+ }
45
+ }
46
+ if (start < 0) return null;
47
+
48
+ const body: string[] = [];
49
+ for (let i = start; i < lines.length; i++) {
50
+ const line = lines[i]!;
51
+ if (/^##\s/.test(line.trim())) break;
52
+ body.push(line);
53
+ }
54
+ return body.join('\n');
55
+ }
56
+
57
+ export function isFilledDraftQuality(sectionBody: string): boolean {
58
+ const withoutComments = sectionBody.replace(/<!--[\s\S]*?-->/g, '');
59
+ return withoutComments.trim().length > 0;
60
+ }
61
+
62
+ export interface DocumentationPresenceInput {
63
+ /** Full LAYOUT.md text, or `null` when the file is missing. */
64
+ layoutMd: string | null;
65
+ /** Whether docs/DEVPLAN.md exists on disk. */
66
+ devplanExists: boolean;
67
+ /** From {@link isCreateProducedRepo}; only create repos require DEVPLAN.md. */
68
+ isCreateRepo: boolean;
69
+ /** Docs directory name used in violation locations (default `docs`). */
70
+ docsDir?: string;
71
+ }
72
+
73
+ /**
74
+ * Documentation-presence check for `check --fab` (task 1.5):
75
+ * - LAYOUT.md must have a filled `## Draft quality` section
76
+ * - create-produced repos must also have DEVPLAN.md
77
+ *
78
+ * Failures use the drift-report voice: file as location, claim `"release-ready"`.
79
+ */
80
+ export function checkDocumentationPresence(input: DocumentationPresenceInput): FabCheckResult {
81
+ const docs = (input.docsDir ?? 'docs').replace(/[/\\]+$/, '');
82
+ const violations: FabViolation[] = [];
83
+ const layoutLoc = `${docs}/LAYOUT.md`;
84
+ const claim = 'release-ready';
85
+
86
+ if (input.layoutMd === null) {
87
+ violations.push({
88
+ claim,
89
+ actual: 'LAYOUT.md missing',
90
+ location: layoutLoc,
91
+ });
92
+ } else {
93
+ const section = draftQualitySection(input.layoutMd);
94
+ if (section === null) {
95
+ violations.push({
96
+ claim,
97
+ actual: `missing ${DRAFT_QUALITY_HEADING} section`,
98
+ location: layoutLoc,
99
+ });
100
+ } else if (!isFilledDraftQuality(section)) {
101
+ violations.push({
102
+ claim,
103
+ actual: `${DRAFT_QUALITY_HEADING} section is empty`,
104
+ location: layoutLoc,
105
+ });
106
+ }
107
+ }
108
+
109
+ if (input.isCreateRepo && !input.devplanExists) {
110
+ violations.push({
111
+ claim,
112
+ actual: 'missing',
113
+ location: `${docs}/DEVPLAN.md`,
114
+ });
115
+ }
116
+
117
+ return {
118
+ status: violations.length === 0 ? 'pass' : 'fail',
119
+ violations,
120
+ };
121
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Shared markdown-table parsing for BOM.md and PINOUT.md (design D9's
3
+ * fixed-column contract). Originally lived inline in drift.ts; pulled out so
4
+ * the supplier BOM export work (add-supplier-bom-export) can parse BOM.md
5
+ * once, the same way, instead of duplicating this.
6
+ */
7
+
8
+ export interface TableRow {
9
+ cells: string[];
10
+ }
11
+
12
+ /**
13
+ * Parses every markdown pipe-table row out of a document, across however
14
+ * many tables the file contains, skipping separator rows (e.g. `|---|---|`).
15
+ * Malformed lines (stray `|` outside a real table) just become a row with
16
+ * whatever cells they split into — this function never throws.
17
+ */
18
+ export function parseMarkdownTables(md: string): TableRow[] {
19
+ const rows: TableRow[] = [];
20
+ for (const line of md.split('\n')) {
21
+ const t = line.trim();
22
+ if (!t.startsWith('|')) continue;
23
+ const cells = t
24
+ .split('|')
25
+ .slice(1, -1)
26
+ .map((c) => c.trim());
27
+ if (cells.every((c) => /^:?-+:?$/.test(c))) continue; // separator row
28
+ rows.push({ cells });
29
+ }
30
+ return rows;
31
+ }
32
+
33
+ /** True for a table's header row. BOM.md and PINOUT.md both lead with a
34
+ * Refdes or Pin column, so one check covers both doc types. */
35
+ export const isHeader = (row: TableRow): boolean =>
36
+ row.cells.some((c) => /^(refdes|pin)$/i.test(c));
37
+
38
+ /**
39
+ * A typed BOM.md data row, per the fixed column contract that `init` writes
40
+ * (Refdes | Value | Footprint | MPN | Rationale — see scaffold.ts's
41
+ * `bomTable`). `flags` currently only ever contains `UNVERIFIED` (the MPN
42
+ * column literally says so) or `MISSING_MPN` (no MPN column value at all);
43
+ * more may be added as the export/fab-gate work grows.
44
+ */
45
+ export interface BomRow {
46
+ refdes: string;
47
+ value?: string;
48
+ footprint?: string;
49
+ mpn?: string;
50
+ flags: string[];
51
+ }
52
+
53
+ /**
54
+ * Parses BOM.md's data rows into typed rows. Rows without a refdes in
55
+ * column 1 are dropped rather than thrown on: a hand-edited doc with a
56
+ * ragged or partial table shouldn't crash `check` or `export bom`, it
57
+ * should just be skipped (drift/export callers report the gaps that
58
+ * matter through their own comparisons against the schematic).
59
+ */
60
+ export function parseBomTable(md: string): BomRow[] {
61
+ const rows = parseMarkdownTables(md).filter((r) => !isHeader(r));
62
+ const out: BomRow[] = [];
63
+ for (const row of rows) {
64
+ const [refdes, value, footprint, mpn] = row.cells;
65
+ if (!refdes) continue;
66
+ const flags: string[] = [];
67
+ if (mpn === 'UNVERIFIED') flags.push('UNVERIFIED');
68
+ else if (!mpn) flags.push('MISSING_MPN');
69
+ out.push({
70
+ refdes,
71
+ value: value || undefined,
72
+ footprint: footprint || undefined,
73
+ mpn: mpn || undefined,
74
+ flags,
75
+ });
76
+ }
77
+ return out;
78
+ }
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { listSymbols, pinNets, type SchematicSymbol } from '../kicad/sexp.js';
5
+ import { parseMarkdownTables, isHeader } from './bom-table.js';
5
6
 
6
7
  /**
7
8
  * Doc-vs-schematic drift check (AC-2.3). BOM.md and PINOUT.md use fixed table
@@ -13,28 +14,6 @@ export interface DriftMismatch {
13
14
  actual: string;
14
15
  }
15
16
 
16
- export interface TableRow {
17
- cells: string[];
18
- }
19
-
20
- export function parseMarkdownTables(md: string): TableRow[] {
21
- const rows: TableRow[] = [];
22
- for (const line of md.split('\n')) {
23
- const t = line.trim();
24
- if (!t.startsWith('|')) continue;
25
- const cells = t
26
- .split('|')
27
- .slice(1, -1)
28
- .map((c) => c.trim());
29
- if (cells.every((c) => /^:?-+:?$/.test(c))) continue; // separator row
30
- rows.push({ cells });
31
- }
32
- return rows;
33
- }
34
-
35
- const isHeader = (row: TableRow): boolean =>
36
- row.cells.some((c) => /^(refdes|pin)$/i.test(c));
37
-
38
17
  /**
39
18
  * The zero-symbol carve-out in checkDrift is right for the create pipeline,
40
19
  * but it would let `check` silently pass an established repo whose schematic