# Module Acceptance Criteria (Shared Reference)

> **Loaded by:** step-00-validate (pre-gate), step-01-transform (post-check)
> **Purpose:** Define measurable, bash-verifiable acceptance criteria that a module MUST pass.
> **Key principle:** These checks read the REAL JSON files on disk — NOT in-memory data.

---

## Architecture: Flat-File Format

`/business-analyse` generates **separate JSON files** per module:

| File | Content | Root key (canonical) | Accepted variants |
|------|---------|----------------------|-------------------|
| `index.json` | Metadata, file references, summary | `metadata`, `files`, `summary` | — |
| `entities.json` | Entity definitions | `entities[]` | — |
| `rules.json` | Business rules | `rules[]` | `businessRules[]` |
| `usecases.json` | Use cases | `useCases[]` | `usecases[]` (lowercase) |
| `permissions.json` | Roles and permission matrix | `permissionPaths[]` | `permissionSet.permissions[]`, `basePermissions[]`, `actions[]`, `permissions[]` |
| `screens.json` | UI screen specifications | `screens[]` | `sections[].resources[]`, `sections[]`, `wireframes[]` |

The validation script **assembles** these files before checking.

---

## AC Split: Input vs Output

ACs are split into two groups based on **when** they can be validated:

### Input ACs (checked in step-00-validate)

These verify data that `/business-analyse` produces:

| # | Criterion | Minimum | Source File | Blocking |
|---|-----------|---------|-------------|----------|
| AC-01 | Entities present | 1 (or referencedEntities) | `entities.json > entities[]` | YES |
| AC-02 | Entity attributes have `type` field | ALL | `entities.json > entities[].attributes[].type` | YES |
| AC-03 | Use cases present | 2 | `usecases.json > useCases[]` | YES |
| AC-04 | Business rules present | 2 | `rules.json > rules[]` | YES |
| AC-05 | Screens present | 1 | `screens.json > screens[]` | YES |
| AC-06 | Permissions present | 1 | `permissions.json > permissionPaths[]` | YES |
| AC-07 | Module status consolidated or specified | match | `index.json > metadata.status` | YES |

### Output ACs (checked in step-01 post-transform)

These verify data that `/business-analyse-handoff` step-01 generates:

| # | Criterion | Minimum | Location | Blocking |
|---|-----------|---------|----------|----------|
| AC-08 | Handoff has 8 categories | 8 | `handoff.filesToCreate` | YES |
| AC-09 | BR-to-code mapping present | 1 | `handoff.brToCodeMapping[]` | YES |
| AC-10 | API endpoint summary present | 1 | `handoff.apiEndpointSummary[]` | YES |
| AC-11 | SeedData entries have category | ALL | `handoff.filesToCreate.seedData[].category` | YES |
| AC-12 | `specificationFiles` present with 5 paths | 5 | `handoff.specificationFiles` | YES |
| AC-13 | Companion entities file exists and count matches source | match | `.ralph/prd-{module}.entities.json` | YES |
| AC-14 | Companion rules file exists and count matches source | match | `.ralph/prd-{module}.rules.json` | YES |
| AC-15 | Companion usecases file exists and count matches source | match | `.ralph/prd-{module}.usecases.json` | YES |
| AC-16 | Companion screens file exists (if screens.json exists) | exists | `.ralph/prd-{module}.screens.json` | YES |
| AC-17 | Each `brToCodeMapping[].statement` non-empty | ALL | `handoff.brToCodeMapping[].statement` | YES |

---

## Input Validation Script (step-00)

> **Usage:** Validates BA flat files before transformation.
> Resolves file paths from module `index.json > files` references.

```bash
MODULE_DIR="{module_ba_dir}"
node -e "
const fs = require('fs');
const path = require('path');
const dir = process.argv[1];

// Load index.json (optional — may not exist at module level)
let index = {};
const indexPath = path.join(dir, 'index.json');
if (fs.existsSync(indexPath)) {
  index = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
}
const files = index.files || {};

// Load flat files: try index.json refs first, fallback to direct file load
function loadFile(key) {
  // Strategy 1: resolve via index.json > files references
  const ref = files[key];
  if (ref && ref.path) {
    const filePath = path.join(dir, ref.path);
    if (fs.existsSync(filePath)) {
      return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
    }
  }
  // Strategy 2: direct file load (module dirs without index.json)
  const directPath = path.join(dir, key + '.json');
  if (fs.existsSync(directPath)) {
    return JSON.parse(fs.readFileSync(directPath, 'utf-8'));
  }
  return null;
}

const entitiesData = loadFile('entities') || {};
const usecasesData = loadFile('usecases') || {};
const rulesData = loadFile('rules') || {};
const permissionsData = loadFile('permissions') || {};
const screensData = loadFile('screens') || {};

// NORMALISATION: tolerate ba-writer + LLM field name variants
const entities = entitiesData.entities || [];
const hasReferencedEntities = (entitiesData.referencedEntities || []).length > 0;
const useCases = usecasesData.useCases || usecasesData.usecases || [];
const rules = rulesData.rules || rulesData.businessRules || [];

// Permissions: try all known variants, extract into flat array
function extractPermissions(data) {
  if (data.permissionPaths && data.permissionPaths.length > 0) return data.permissionPaths;
  if (data.permissionSet && data.permissionSet.permissions && data.permissionSet.permissions.length > 0) {
    return data.permissionSet.permissions.map(function(p) { return p.code || p.path || ''; });
  }
  if (data.basePermissions && data.basePermissions.length > 0) {
    return data.basePermissions.map(function(p) { return p.code || p.path || ''; });
  }
  if (data.actions && data.actions.length > 0) {
    return data.actions.map(function(a) { return a.code || ''; });
  }
  if (data.permissions && data.permissions.length > 0) {
    return data.permissions.map(function(p) { return typeof p === 'string' ? p : (p.code || p.path || ''); });
  }
  return [];
}
const permissions = extractPermissions(permissionsData);

// Screens: canonical screens[] OR sections[] (with or without .resources sub-array)
function extractScreens(data) {
  if (data.screens && data.screens.length > 0) return data.screens;
  if (data.sections && data.sections.length > 0) {
    // sections may contain resources[] or be screens themselves
    var fromResources = data.sections.reduce(function(acc, s) { return acc.concat(s.resources || []); }, []);
    return fromResources.length > 0 ? fromResources : data.sections;
  }
  if (data.wireframes && data.wireframes.length > 0) return data.wireframes;
  return [];
}
const allScreens = extractScreens(screensData);

// Status: from index.json if present, or 'specified' if module has thematic files but no index.json
const status = (index.metadata && index.metadata.status) || (Object.keys(index).length === 0 ? 'specified' : 'unknown');

const fails = [];

// AC-01: Entities >= 1 (exempt modules with referencedEntities - vue perso pattern)
if (entities.length < 1 && !hasReferencedEntities) {
  fails.push('AC-01: entities = ' + entities.length + ' (min: 1)');
  console.error('FAIL: AC-01: entities >= 1 = ' + entities.length + ' (min: 1, no referencedEntities)');
}

// AC-02: Entity attributes have type
const badAttrs = entities.flatMap(e =>
  (e.attributes || []).filter(a => !a.type).map(a => e.name + '.' + a.name)
);
if (badAttrs.length > 0) {
  fails.push('AC-02: ' + badAttrs.length + ' attributes without type');
  console.error('FAIL: AC-02: ' + badAttrs.length + ' attributes without type: ' + badAttrs.slice(0, 5).join(', '));
}

// AC-03: Use cases >= 2
if (useCases.length < 2) {
  fails.push('AC-03: useCases = ' + useCases.length + ' (min: 2)');
  console.error('FAIL: AC-03: useCases >= 2 = ' + useCases.length + ' (min: 2)');
}

// AC-04: Business rules >= 2
if (rules.length < 2) {
  fails.push('AC-04: rules = ' + rules.length + ' (min: 2)');
  console.error('FAIL: AC-04: rules >= 2 = ' + rules.length + ' (min: 2)');
}

// AC-05: Screens >= 1
if (allScreens.length < 1) {
  fails.push('AC-05: screens = ' + allScreens.length + ' (min: 1)');
  console.error('FAIL: AC-05: screens >= 1 = ' + allScreens.length + ' (min: 1)');
}

// AC-06: Permissions >= 1
if (permissions.length < 1) {
  fails.push('AC-06: permissions = ' + permissions.length + ' (min: 1)');
  console.error('FAIL: AC-06: permissions >= 1 = ' + permissions.length + ' (min: 1)');
}

// AC-07: Status consolidated or specified
if (status !== 'consolidated' && status !== 'specified') {
  fails.push('AC-07: status = ' + status + ' (expected: consolidated or specified)');
  console.error('FAIL: AC-07: status = ' + status + ' (expected: consolidated or specified)');
}

// Summary
if (fails.length > 0) {
  console.error('\\nBLOCKING: ' + fails.length + ' input acceptance criteria FAILED');
  process.exit(1);
}
console.log('PASS: All input acceptance criteria met (AC-01 to AC-07)');
console.log('  entities: ' + entities.length + (hasReferencedEntities ? ' (+ referencedEntities)' : '') + ', useCases: ' + useCases.length + ', rules: ' + rules.length + ', screens: ' + allScreens.length + ', permissions: ' + permissions.length);
" "$MODULE_DIR"
```

---

## Output Validation Script (step-01 post-check)

> **Usage:** Validates handoff data after step-01 transformation.

```bash
MODULE_JSON="{module_index_json_path}"
node -e "
const fs = require('fs');
const data = JSON.parse(fs.readFileSync(process.argv[1], 'utf-8'));
const handoff = data.handoff || {};
const ftc = handoff.filesToCreate || {};

const fails = [];

// AC-08: All 8 categories present
const categories = ['domain', 'application', 'infrastructure', 'api', 'frontend', 'seedData', 'tests', 'documentation'];
const missing = categories.filter(cat => !ftc[cat]);
if (missing.length > 0) {
  fails.push('AC-08: missing categories: ' + missing.join(', '));
  console.error('FAIL: AC-08: missing categories: ' + missing.join(', '));
}

// AC-09: BR-to-code mapping present
if (!handoff.brToCodeMapping || handoff.brToCodeMapping.length === 0) {
  fails.push('AC-09: brToCodeMapping is empty');
  console.error('FAIL: AC-09: brToCodeMapping is empty');
}

// AC-10: API endpoint summary present
if (!handoff.apiEndpointSummary || handoff.apiEndpointSummary.length === 0) {
  fails.push('AC-10: apiEndpointSummary is empty');
  console.error('FAIL: AC-10: apiEndpointSummary is empty');
}

// AC-11: SeedData entries have category
const seedEntries = ftc.seedData || [];
const noCat = seedEntries.filter(s => !s.category);
if (noCat.length > 0) {
  fails.push('AC-11: ' + noCat.length + ' seedData entries missing category');
  console.error('FAIL: AC-11: ' + noCat.length + ' seedData entries missing category field');
}

// AC-12: specificationFiles present with 5 paths
const specFiles = handoff.specificationFiles || {};
const specKeys = ['entities', 'rules', 'usecases', 'screens', 'permissions'];
const missingSpecs = specKeys.filter(k => !specFiles[k]);
if (missingSpecs.length > 0) {
  fails.push('AC-12: specificationFiles missing keys: ' + missingSpecs.join(', '));
  console.error('FAIL: AC-12: specificationFiles missing keys: ' + missingSpecs.join(', '));
}

// AC-13 to AC-16: Companion files exist and counts match
const ralphDir = path.dirname(process.argv[1]);
const companionChecks = [
  { ac: 'AC-13', key: 'entities', arrayKey: 'entities' },
  { ac: 'AC-14', key: 'rules', arrayKey: 'rules' },
  { ac: 'AC-15', key: 'usecases', arrayKey: 'useCases' },
  { ac: 'AC-16', key: 'screens', arrayKey: 'screens' }
];
for (const check of companionChecks) {
  const companionPath = specFiles[check.key] ? path.join(ralphDir, specFiles[check.key]) : null;
  if (companionPath && fs.existsSync(companionPath)) {
    const companionData = JSON.parse(fs.readFileSync(companionPath, 'utf-8'));
    const arr = companionData[check.arrayKey] || companionData[check.arrayKey.toLowerCase()] || [];
    if (arr.length === 0 && check.ac !== 'AC-16') {
      fails.push(check.ac + ': companion ' + check.key + ' file is empty');
      console.error('FAIL: ' + check.ac + ': companion ' + check.key + ' file has 0 entries');
    }
  } else if (companionPath) {
    fails.push(check.ac + ': companion file missing: ' + specFiles[check.key]);
    console.error('FAIL: ' + check.ac + ': companion file not found: ' + specFiles[check.key]);
  }
}

// AC-17: Each brToCodeMapping[].statement non-empty
const brMapping = handoff.brToCodeMapping || [];
const emptyStatements = brMapping.filter(br => !br.statement || br.statement.trim() === '');
if (emptyStatements.length > 0) {
  fails.push('AC-17: ' + emptyStatements.length + ' BRs with empty statement');
  console.error('FAIL: AC-17: ' + emptyStatements.length + ' brToCodeMapping entries have empty statement: ' + emptyStatements.slice(0, 3).map(b => b.ruleId).join(', '));
}

if (fails.length > 0) {
  console.error('\\nBLOCKING: ' + fails.length + ' output acceptance criteria FAILED');
  process.exit(1);
}
console.log('PASS: All output acceptance criteria met (AC-08 to AC-17)');
" "$MODULE_JSON"
```

---

## Recovery Actions

| Criterion | Recovery |
|-----------|----------|
| AC-01/02 | Re-run `/business-analyse` entity specification |
| AC-03 | Re-run `/business-analyse` use case specification |
| AC-04 | Re-run `/business-analyse` business rules specification |
| AC-05 | Re-run `/business-analyse` screen specification |
| AC-06 | Re-run `/business-analyse` permission specification |
| AC-07 | Run `/business-analyse` step-04 consolidation |
| AC-08 | Re-run `/business-analyse-handoff` step-01 transform (missing categories) |
| AC-09 | Re-run `/business-analyse-handoff` step-01 transform (BR mapping) |
| AC-10 | Re-run `/business-analyse-handoff` step-01 transform (API endpoints) |
| AC-11 | Re-run `/business-analyse-handoff` step-01 transform (seedData categories) |
| AC-12 | Re-run `/business-analyse-handoff` step-01 transform (specificationFiles missing) |
| AC-13 | Re-run `/business-analyse-handoff` step-01 transform (entities companion missing/empty) |
| AC-14 | Re-run `/business-analyse-handoff` step-01 transform (rules companion missing/empty) |
| AC-15 | Re-run `/business-analyse-handoff` step-01 transform (usecases companion missing/empty) |
| AC-16 | Re-run `/business-analyse-handoff` step-01 transform (screens companion missing) |
| AC-17 | Re-run `/business-analyse-handoff` step-01 transform (BR statements empty — copy from rules.json) |
