@metasession.co/devaudit-cli 0.1.33 → 0.1.34

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metasession.co/devaudit-cli",
3
- "version": "0.1.33",
3
+ "version": "0.1.34",
4
4
  "description": "DevAudit CLI — installs, syncs, and operates the Metasession SDLC across consumer projects.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@clack/prompts": "^0.8.2",
36
- "@metasession.co/devaudit-plugin-sdk": "^0.1.33",
36
+ "@metasession.co/devaudit-plugin-sdk": "^0.1.34",
37
37
  "commander": "^12.1.0",
38
38
  "consola": "^3.2.3",
39
39
  "env-paths": "^3.0.0",
@@ -72,6 +72,11 @@ EVIDENCE_CATEGORY=""
72
72
  RELEASE_TITLE=""
73
73
  CHANGE_TYPE=""
74
74
  GATE_STATUS=""
75
+ # Repeatable `--meta-key key=value` accumulator. Each pair gets merged
76
+ # into the metadata JSON sent to the portal. Used by the screenshot
77
+ # upload loop to pass `origin=feature|regression` from the per-PNG
78
+ # sidecar JSON written by the evidenceShot helper.
79
+ META_KEYS=()
75
80
 
76
81
  while [ "$#" -gt 0 ]; do
77
82
  case "$1" in
@@ -91,6 +96,17 @@ while [ "$#" -gt 0 ]; do
91
96
  # ran-and-failed != never-ran. Unknown values dropped server-side.
92
97
  # DevAudit-Installer#96.
93
98
  --gate-status) GATE_STATUS="$2"; shift 2 ;;
99
+ # --meta-key key=value (repeatable). Merged into the metadata JSON
100
+ # before posting. Validates the `key=value` shape; rejects bare
101
+ # keys without `=`.
102
+ --meta-key)
103
+ if [[ "$2" != *=* ]]; then
104
+ echo "Error: --meta-key requires key=value (got: $2)"
105
+ exit 1
106
+ fi
107
+ META_KEYS+=("$2")
108
+ shift 2
109
+ ;;
94
110
  *) echo "Unknown option: $1"; exit 1 ;;
95
111
  esac
96
112
  done
@@ -118,24 +134,27 @@ fi
118
134
  DEVAUDIT_BASE_URL="${DEVAUDIT_BASE_URL%/}"
119
135
 
120
136
  # --- Build metadata JSON ---
121
- METADATA="{}"
122
- if [ -n "$GIT_SHA" ] || [ -n "$CI_RUN_ID" ] || [ -n "$BRANCH" ]; then
123
- METADATA="{"
124
- FIRST=true
125
- if [ -n "$GIT_SHA" ]; then
126
- METADATA="${METADATA}\"gitSha\":\"${GIT_SHA}\""
127
- FIRST=false
128
- fi
129
- if [ -n "$CI_RUN_ID" ]; then
130
- [ "$FIRST" = false ] && METADATA="${METADATA},"
131
- METADATA="${METADATA}\"ciRunId\":\"${CI_RUN_ID}\""
132
- FIRST=false
133
- fi
134
- if [ -n "$BRANCH" ]; then
135
- [ "$FIRST" = false ] && METADATA="${METADATA},"
136
- METADATA="${METADATA}\"branch\":\"${BRANCH}\""
137
- fi
138
- METADATA="${METADATA}}"
137
+ # Assemble entries first; only emit `{ ... }` if at least one field is
138
+ # set. Each entry is a `"key":"value"` JSON pair with the value
139
+ # json-escaped (quotes + backslashes).
140
+ json_escape() {
141
+ printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
142
+ }
143
+ META_ENTRIES=()
144
+ [ -n "$GIT_SHA" ] && META_ENTRIES+=("\"gitSha\":\"$(json_escape "$GIT_SHA")\"")
145
+ [ -n "$CI_RUN_ID" ] && META_ENTRIES+=("\"ciRunId\":\"$(json_escape "$CI_RUN_ID")\"")
146
+ [ -n "$BRANCH" ] && META_ENTRIES+=("\"branch\":\"$(json_escape "$BRANCH")\"")
147
+ for KV in "${META_KEYS[@]}"; do
148
+ KEY="${KV%%=*}"
149
+ VAL="${KV#*=}"
150
+ META_ENTRIES+=("\"$(json_escape "$KEY")\":\"$(json_escape "$VAL")\"")
151
+ done
152
+ if [ "${#META_ENTRIES[@]}" -gt 0 ]; then
153
+ IFS=','
154
+ METADATA="{${META_ENTRIES[*]}}"
155
+ unset IFS
156
+ else
157
+ METADATA="{}"
139
158
  fi
140
159
 
141
160
  # --- Collect files ---
@@ -193,6 +193,20 @@ Then bucket each failure:
193
193
 
194
194
  **Then check for missed requirements.** For each numbered acceptance criterion from Phase 2, confirm at least one *passing* test covers it. An AC with no passing test — because no test was written, or because the test fails — is a missed requirement. File it.
195
195
 
196
+ ### Phase 7 — Regression-pack handoff
197
+
198
+ After Phase 6 succeeds — green run, all ACs proved, defects filed for anything missing — the new spec(s) you authored move into the project's regression pack. There is **no separate graduation step**. The pack is defined as:
199
+
200
+ > **Every `*.spec.ts` (and `*.spec.tsx`) under `tests/e2e/` or `e2e/`.**
201
+
202
+ There is no `regression/` sub-directory, no `@regression` tag, no manifest file. Being committed and merged to `develop` *is* the graduation step. Once that happens:
203
+
204
+ 1. The next CI run (on this branch's PR, or on `develop` after merge) executes the new spec alongside every existing one.
205
+ 2. The evidenceShot helper sees `process.env.E2E_NEW_SPECS` (computed by CI as `git diff --diff-filter=A <merge-base>...HEAD`) and tags this branch's captures as `origin: 'feature'`.
206
+ 3. Post-merge, develop runs see an empty `E2E_NEW_SPECS` and tag every capture as `origin: 'regression'`. The original feature-branch captures stay tagged `feature` as the historical proof of original landing; subsequent develop runs accumulate `regression` captures alongside.
207
+
208
+ You don't need to do anything explicit for this step — it's a property of the pipeline, not an action. Surface it in the final report so the reviewer knows the new tests are now load-bearing for every future release.
209
+
196
210
  ### Filing defects
197
211
 
198
212
  Use whatever tracker integration you found in Phase 1: `gh issue create`, `glab issue create`, a Jira or Linear MCP tool, `az boards work-item create`. If nothing is available, produce a markdown report with each defect formatted ready to paste.
@@ -234,7 +248,7 @@ import { evidenceShot } from './helpers/evidence';
234
248
  test('AC1: edit dialog opens with fields pre-filled', async ({ page }) => {
235
249
  await openEditDialog(page, item.id);
236
250
  await expect(dialog.locator('#name')).toHaveValue(item.name);
237
- await evidenceShot(page, 'REQ-037', 'AC1-edit-dialog-prefilled');
251
+ await evidenceShot(page, 'REQ-037', 1, 'edit-dialog-prefilled');
238
252
  // ...rest of test
239
253
  });
240
254
  ```
@@ -242,11 +256,14 @@ test('AC1: edit dialog opens with fields pre-filled', async ({ page }) => {
242
256
  **Discipline:**
243
257
 
244
258
  - Call `evidenceShot` **immediately after** the AC-proving assertion, before navigating, closing dialogs, or any further interaction.
245
- - Slug as `AC<n>-<what-this-proves>` the filename documents the claim.
259
+ - AC number is a separate argument (`ac: number`) — the helper composes the filename `REQ-XXX-AC<n>-<slug>.png`. The slug describes what the screenshot proves (`edit-dialog-prefilled`), NOT the AC number.
260
+ - Slug is kebab-case lowercase (`[a-z0-9-]+`). Capitalised slugs, underscores, or spaces throw.
246
261
  - One screenshot per AC, not per test.
247
262
  - Failure forensics stays untouched (`screenshot: 'only-on-failure'` + `trace: 'on-first-retry'`).
248
263
 
249
- The helper is shipped automatically into `e2e/helpers/evidence.ts` by the SDLC sync (node-stack consumers). Output lands at `compliance/evidence/<REQ-ID>/screenshots/<slug>.png` — commit these PNGs as part of the evidence pack so reviewers can corroborate the test-plan AC mapping.
264
+ The helper is shipped automatically into `e2e/helpers/evidence.ts` by the SDLC sync (node-stack consumers). Output lands at `compliance/evidence/<REQ-ID>/screenshots/REQ-XXX-AC<n>-<slug>.png` — commit these PNGs as part of the evidence pack so reviewers can corroborate the test-plan AC mapping.
265
+
266
+ The helper also writes a sidecar `<filename>.meta.json` containing the AC mapping + the screenshot's **origin** — `feature` if the spec was added on the current branch, `regression` if the spec already existed. The consumer's CI passes `origin` through to the DevAudit portal as evidence metadata so the release-detail page can render feature vs regression captures distinctly. Auto-detected from `process.env.E2E_NEW_SPECS` — no manual tagging required.
250
267
 
251
268
  The canonical helper source lives at `references/evidence.ts` in this skill.
252
269
 
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Pure helpers backing `evidenceShot`. Kept Playwright-free so they
3
+ * can be unit-tested without pulling `@playwright/test` into the
4
+ * installer repo. The thin wrapper in `evidence.ts` does the Page
5
+ * screenshot + fs writes around these.
6
+ */
7
+
8
+ export type EvidenceShotOrigin = 'feature' | 'regression';
9
+
10
+ export interface EvidenceShotSidecar {
11
+ readonly origin: EvidenceShotOrigin;
12
+ readonly reqId: string;
13
+ readonly ac: number;
14
+ readonly slug: string;
15
+ readonly specFile: string;
16
+ readonly capturedAt: string;
17
+ }
18
+
19
+ const REQ_ID_RE = /^REQ-[A-Z0-9-]+$/;
20
+ const SLUG_RE = /^[a-z0-9-]+$/;
21
+
22
+ export function validateEvidenceShotInputs(reqId: string, ac: number, slug: string): void {
23
+ if (!REQ_ID_RE.test(reqId)) {
24
+ throw new Error(`evidenceShot: invalid reqId "${reqId}" (must match ${REQ_ID_RE})`);
25
+ }
26
+ if (!Number.isInteger(ac) || ac <= 0) {
27
+ throw new Error(`evidenceShot: invalid ac "${ac}" (must be a positive integer)`);
28
+ }
29
+ if (!SLUG_RE.test(slug)) {
30
+ throw new Error(
31
+ `evidenceShot: invalid slug "${slug}" (must match ${SLUG_RE} — kebab-case, no AC prefix)`,
32
+ );
33
+ }
34
+ }
35
+
36
+ export function composeScreenshotFilename(reqId: string, ac: number, slug: string): string {
37
+ return `${reqId}-AC${ac}-${slug}.png`;
38
+ }
39
+
40
+ /**
41
+ * Auto-detect origin from `process.env.E2E_NEW_SPECS` — the consumer
42
+ * globalSetup writes the newline-delimited list of spec files added
43
+ * on the current branch (`git diff --diff-filter=A`). If the calling
44
+ * spec appears in that list, it's `feature`; otherwise `regression`.
45
+ *
46
+ * Empty / missing env (typical for post-merge develop runs) → every
47
+ * capture is `regression`, which is the correct semantic outcome.
48
+ */
49
+ export function autoDetectEvidenceShotOrigin(
50
+ specFile: string,
51
+ newSpecsEnv: string | undefined,
52
+ ): EvidenceShotOrigin {
53
+ const list = (newSpecsEnv ?? '').trim();
54
+ if (list.length === 0) return 'regression';
55
+ const newSpecs = new Set(
56
+ list
57
+ .split(/\r?\n/)
58
+ .map((s) => s.trim())
59
+ .filter(Boolean),
60
+ );
61
+ return newSpecs.has(specFile) ? 'feature' : 'regression';
62
+ }
@@ -1,40 +1,94 @@
1
+ import fs from 'fs';
1
2
  import path from 'path';
2
- import { type Page } from '@playwright/test';
3
+ import { test, type Page } from '@playwright/test';
4
+ import {
5
+ autoDetectEvidenceShotOrigin,
6
+ composeScreenshotFilename,
7
+ validateEvidenceShotInputs,
8
+ type EvidenceShotOrigin,
9
+ type EvidenceShotSidecar,
10
+ } from './evidence-shot-core';
3
11
 
4
- const SLUG_RE = /^[A-Za-z0-9_-]+$/;
12
+ export type { EvidenceShotOrigin };
13
+
14
+ export interface EvidenceShotOptions {
15
+ /** Capture the full page rather than the viewport. Default: true. */
16
+ readonly fullPage?: boolean;
17
+ /**
18
+ * Override the auto-detected origin. By default the helper consults
19
+ * `process.env.E2E_NEW_SPECS` — set by the consumer's Playwright
20
+ * `globalSetup` step in CI — and tags the screenshot `feature` if
21
+ * the calling spec's file appears in that list, else `regression`.
22
+ */
23
+ readonly origin?: EvidenceShotOrigin;
24
+ }
5
25
 
6
26
  /**
7
- * Write a per-assertion screenshot into the requirement's evidence pack.
27
+ * Write a per-AC behavioural-proof screenshot into the requirement's
28
+ * evidence pack.
8
29
  *
9
30
  * Call this AT the assertion that proves the AC, before any further
10
31
  * interaction or navigation. The PNG is committed as part of the
11
32
  * evidence pack and used by reviewers to corroborate the test-plan
12
33
  * AC mapping.
13
34
  *
14
- * Output path: `compliance/evidence/<reqId>/screenshots/<slug>.png`
35
+ * Filename: `REQ-XXX-AC<n>-<slug>.png`
36
+ * Output path: `compliance/evidence/<reqId>/screenshots/<filename>`
37
+ *
38
+ * The helper also writes a sidecar `<filename>.meta.json` containing
39
+ * `{ origin, reqId, ac, slug, specFile, capturedAt }`. The consumer's
40
+ * CI upload step reads the sidecar and passes `origin` as evidence
41
+ * metadata to the portal so the release-detail page can tell feature
42
+ * captures apart from regression-pack reruns.
15
43
  *
16
44
  * @example
17
45
  * await expect(dialog.locator('#name')).toHaveValue(item.name);
18
- * await evidenceShot(page, 'REQ-037', 'AC1-edit-dialog-prefilled');
46
+ * await evidenceShot(page, 'REQ-037', 1, 'edit-dialog-prefilled');
47
+ *
48
+ * @param page Playwright Page
49
+ * @param reqId `REQ-` prefixed requirement id (e.g. `REQ-037`)
50
+ * @param ac AC number — mandatory; every screenshot proves one AC
51
+ * @param slug kebab-case descriptive slug (no `AC<n>-` prefix; the
52
+ * helper composes it from `ac`)
19
53
  */
20
54
  export async function evidenceShot(
21
55
  page: Page,
22
56
  reqId: string,
57
+ ac: number,
23
58
  slug: string,
24
- opts: { fullPage?: boolean } = {},
59
+ opts: EvidenceShotOptions = {},
25
60
  ): Promise<void> {
26
- if (!SLUG_RE.test(reqId)) {
27
- throw new Error(`evidenceShot: invalid reqId "${reqId}" (must match ${SLUG_RE})`);
28
- }
29
- if (!SLUG_RE.test(slug)) {
30
- throw new Error(`evidenceShot: invalid slug "${slug}" (must match ${SLUG_RE})`);
31
- }
32
- const out = path.join(
33
- process.cwd(),
34
- 'compliance/evidence',
61
+ validateEvidenceShotInputs(reqId, ac, slug);
62
+ const fileName = composeScreenshotFilename(reqId, ac, slug);
63
+ const dir = path.join(process.cwd(), 'compliance/evidence', reqId, 'screenshots');
64
+ const pngPath = path.join(dir, fileName);
65
+ const sidecarPath = `${pngPath}.meta.json`;
66
+
67
+ await page.screenshot({ path: pngPath, fullPage: opts.fullPage ?? true });
68
+
69
+ const specFile = resolveSpecFile();
70
+ const origin = opts.origin ?? autoDetectEvidenceShotOrigin(specFile, process.env.E2E_NEW_SPECS);
71
+ const sidecar: EvidenceShotSidecar = {
72
+ origin,
35
73
  reqId,
36
- 'screenshots',
37
- `${slug}.png`,
38
- );
39
- await page.screenshot({ path: out, fullPage: opts.fullPage ?? true });
74
+ ac,
75
+ slug,
76
+ specFile,
77
+ capturedAt: new Date().toISOString(),
78
+ };
79
+ await fs.promises.writeFile(sidecarPath, `${JSON.stringify(sidecar, null, 2)}\n`, 'utf8');
80
+ }
81
+
82
+ /**
83
+ * Test-info gives us the absolute spec path. Make it repo-relative so
84
+ * it survives serialisation into the sidecar JSON + the CI's git diff
85
+ * comparison list.
86
+ */
87
+ function resolveSpecFile(): string {
88
+ try {
89
+ const info = test.info();
90
+ return path.relative(process.cwd(), info.file);
91
+ } catch {
92
+ return 'unknown';
93
+ }
40
94
  }
@@ -42,6 +42,12 @@ jobs:
42
42
 
43
43
  steps:
44
44
  - uses: actions/checkout@v4
45
+ with:
46
+ # Full history so the "new specs on this branch" calculation
47
+ # (E2E_NEW_SPECS, below) can do a real diff against the merge
48
+ # base — Playwright's evidenceShot helper reads that env var to
49
+ # tag each captured screenshot as feature vs regression.
50
+ fetch-depth: 0
45
51
 
46
52
  # ── Cached installs (skip if already present on self-hosted runner) ──
47
53
 
@@ -142,6 +148,32 @@ jobs:
142
148
  - name: Wait for dev server
143
149
  run: npx wait-on http://localhost:3000 --timeout 120000
144
150
 
151
+ # Compute the set of e2e spec files added on this branch (relative
152
+ # to the merge base). The evidenceShot helper in the consumer's
153
+ # tests reads E2E_NEW_SPECS at capture time and tags each screenshot
154
+ # `origin=feature` when its spec appears in the list, else
155
+ # `origin=regression`. On main / develop (post-merge) the diff is
156
+ # empty and every capture is `regression`, which is the intended
157
+ # behaviour. Newline-delimited; relative paths.
158
+ - name: Compute new-spec list for E2E origin tagging
159
+ run: |
160
+ BASE_REF="${{ github.base_ref || github.event.repository.default_branch || 'main' }}"
161
+ BASE_SHA=$(git merge-base "origin/${BASE_REF}" HEAD 2>/dev/null || git rev-parse HEAD)
162
+ NEW_SPECS=$(git diff --name-only --diff-filter=A "${BASE_SHA}...HEAD" \
163
+ -- 'tests/e2e/**/*.spec.ts' 'tests/e2e/**/*.spec.tsx' \
164
+ 'e2e/**/*.spec.ts' 'e2e/**/*.spec.tsx' 2>/dev/null || true)
165
+ {
166
+ echo "E2E_NEW_SPECS<<EOF"
167
+ echo "$NEW_SPECS"
168
+ echo "EOF"
169
+ } >> "$GITHUB_ENV"
170
+ if [ -n "$NEW_SPECS" ]; then
171
+ echo "New e2e specs on this branch (origin=feature for captures):"
172
+ echo "$NEW_SPECS" | sed 's/^/ /'
173
+ else
174
+ echo "No new e2e specs detected — all captures will tag origin=regression."
175
+ fi
176
+
145
177
  {{E2E_TEST_STEP}}
146
178
  {{E2E_AUTHENTICATED_STEP}}
147
179
  # ── Gate 5: Build ──
@@ -191,6 +223,7 @@ jobs:
191
223
  coverage/coverage-summary.json
192
224
  gate-outcomes.json
193
225
  compliance/evidence/*/screenshots/*.png
226
+ compliance/evidence/*/screenshots/*.png.meta.json
194
227
  retention-days: 90
195
228
 
196
229
  # ──────────────────────────────────────────────
@@ -486,16 +519,39 @@ jobs:
486
519
  [ -n "$REQ_TITLE" ] && REQ_META+=(--release-title "$REQ_TITLE")
487
520
  [ -n "$REQ_CT" ] && REQ_META+=(--change-type "$REQ_CT")
488
521
  for PNG in "${SHOTS[@]}"; do
489
- # The folder is the (SRS) requirement id, the basename is the AC
490
- # slug (ACn-…). Upload as <srs-req>-<slug>.png so the reviewer can
491
- # see which requirement/AC each image proves and names don't collide.
492
- SRS_REQ="$(basename "$(dirname "$(dirname "$PNG")")")"
493
- NAMED="${SHOT_TMP}/${SRS_REQ}-$(basename "$PNG")"
522
+ # The basename is the canonical evidenceShot filename
523
+ # `REQ-XXX-AC<n>-<slug>.png`. The portal validates this
524
+ # shape on upload anything else is rejected with 400.
525
+ BASE="$(basename "$PNG")"
526
+ NAMED="${SHOT_TMP}/${BASE}"
494
527
  cp "$PNG" "$NAMED" 2>/dev/null || continue
528
+
529
+ # Read the sidecar JSON (`<png>.meta.json`) written by the
530
+ # evidenceShot helper. Pull `origin` out so the portal
531
+ # render can tell feature vs regression captures apart.
532
+ # Sidecar missing → upload with no origin metadata; the
533
+ # portal renders those as "unknown" until consumers re-
534
+ # onboard via `devaudit update`.
535
+ ORIGIN_META=()
536
+ if [ -f "${PNG}.meta.json" ]; then
537
+ ORIGIN=$(python3 -c "
538
+ import json, sys
539
+ try:
540
+ with open('${PNG}.meta.json') as f:
541
+ print(json.load(f).get('origin', ''))
542
+ except Exception:
543
+ pass
544
+ " 2>/dev/null || true)
545
+ if [ "$ORIGIN" = "feature" ] || [ "$ORIGIN" = "regression" ]; then
546
+ ORIGIN_META+=(--meta-key "origin=${ORIGIN}")
547
+ fi
548
+ fi
549
+
495
550
  bash scripts/upload-evidence.sh \
496
551
  {{PROJECT_SLUG}} "$REQ" screenshot "$NAMED" \
497
552
  --category test_report ${FLAGS} --release "$REQ" \
498
553
  "${REQ_META[@]}" \
554
+ "${ORIGIN_META[@]}" \
499
555
  || echo "::warning::evidence screenshot upload failed: ${PNG} -> ${REQ}"
500
556
  done
501
557
  done