/** * cli:derive-change-impact — execute.ts (pure composition over the corpus) * * blocked → owner → existing → allocation → impact (mode=impact) * owner → re-parse → count / lost / duplicates / blocks / sources (mode=verify) * * Fail-closed: a kind whose checklist is not implemented, a missing section, * an unknown actor, an unknown linked UC, a missing owner doc, an * unresolvable modify target — each is a `blocked[]` entry WITH a route, * and `impact[]` stays empty (the skill reads `blocked` first and stops). */ import { ACTOR_HEADING_RE, parseActors } from '../../../../lib/ba-actors.js' import { ENT_HEADING_RE, ENT_NEAR_MISS_RE, isAttributeRowLoss, isIndexLoss, parseEntityDoc, relationLossOf } from '../../../../lib/ba-entities.js' import { parseRbacRows, ROW_RE } from '../../../../lib/ba-rbac-rows.js' import { parseScreenFile, SCREEN_HEADING_RE } from '../../../../lib/ba-screens.js' import { citationsInText, loadSourcesRegistry, resolveCitation, sourcesRootFor } from '../../../../lib/ba-sources.js' import { parseRules, RULE_HEADING_RE } from '../../../../lib/ba-rules-rows.js' import { parseUseCases, UC_HEADING_RE } from '../../../../lib/ba-use-cases.js' import { matchCapabilityTrigger } from '../../../../lib/capability-catalog.js' import { matchCoreEntity, matchReservedCoreName } from '../../../../lib/core-catalog.js' import { FLOOR_BY_GRAIN, parsePermissionPath } from '../../../../lib/permission-actions.js' import { allocateActor, allocateEntity, allocateRule, allocateScreen, allocateUseCase } from './allocate.js' import { machineBlocksHashOf, ownerRulesDoc, rulesDocAt, sectionUseCaseDoc, type ScopeCorpus } from './corpus.js' import { canonicalPortee, existingActors, existingAttributes, existingEntities, existingPermissions, existingRules, existingScreens, existingUseCases, } from './existing.js' import { buildImpact, SCREEN_TYPES } from './impact.js' import { traceTerm } from './trace.js' import { IMPLEMENTED_KINDS, type Allocation, type Blocked, type ChangeImpactInput, type ChangeImpactReport, type ChangeKind, type ExistingReport, type Owner, type SourcesCheck, type TraceReport, type VerifyReport, } from './types.js' const OWNING_SKILL: Record = { 'use-case': 'ba-create-use-case', 'business-rule': 'ba-create-business-rules', actor: 'ba-create-actors', permission: 'ba-create-rbac', attribute: 'ba-create-data-model', entity: 'ba-create-data-model', screen: 'ba-create-screen', } const PLACEHOLDER_RE = /_À définir lors de la phase/ function ownerOf(spec: ChangeImpactInput, corpus: ScopeCorpus, sectionFolder?: string): Owner { const { app, module } = corpus.scope switch (spec.kind) { case 'use-case': { const doc = sectionFolder ? sectionUseCaseDoc(corpus, sectionFolder) : undefined const text = doc?.text ?? '' return { file: `${app}/${module}/${sectionFolder ?? '
'}/use-case.md`, exists: doc !== undefined, placeholder: doc !== undefined && doc.parsed.ucs.length === 0 && PLACEHOLDER_RE.test(text), skill: OWNING_SKILL['use-case'], subWorkflow: 'Phase 2 (detail) — levels/detail.md, ONE UC; full re-Write of the section doc', writeDiscipline: 'full-rewrite', machineBlocksHash: machineBlocksHashOf(text), } } case 'business-rule': { const { doc, fallback } = ownerRulesDoc(corpus, sectionFolder) const text = doc?.text ?? '' return { file: doc ? `${app}/${module}/${doc.relPath}` : `${app}/${module}/${sectionFolder ? `${sectionFolder}/` : ''}règles-métier.md`, exists: doc !== undefined, placeholder: doc !== undefined && doc.rules.length === 0 && PLACEHOLDER_RE.test(text), skill: OWNING_SKILL['business-rule'], subWorkflow: fallback ? '§ Single-UC entry point; the section has no règles-métier.md of its own — the MODULE doc is the deepest existing scope (create the section doc through ba-create-business-rules only if the user wants the rule section-scoped)' : '§ Single-UC entry point; full re-Write of the doc at the rule\'s deepest scope', writeDiscipline: 'full-rewrite', machineBlocksHash: machineBlocksHashOf(text), } } case 'permission': return { file: `${app}/${module}/rbac.md`, exists: corpus.rbac.exists, placeholder: corpus.rbac.exists && corpus.rbac.rows.length === 0, skill: OWNING_SKILL.permission, subWorkflow: 'decision row « add/modify a permission » — human matrix only, machine blocks untouched', writeDiscipline: 'table-row-append', machineBlocksHash: machineBlocksHashOf(corpus.rbac.text), } case 'attribute': case 'entity': return { file: `${app}/${module}/entité.md`, exists: corpus.entityDoc !== null, placeholder: corpus.entities === null || corpus.entities.entities.length === 0, skill: OWNING_SKILL.entity, subWorkflow: 'Step 0 — trace audit, then the entity block', writeDiscipline: spec.kind === 'attribute' ? 'entity-block-splice' : 'full-rewrite', machineBlocksHash: machineBlocksHashOf(corpus.entityDoc?.text ?? ''), } case 'actor': { const a = corpus.actors.find((x) => x.app.toLowerCase() === app.toLowerCase()) return { file: `${app}/acteur.md`, exists: a?.exists ?? false, placeholder: (a?.exists ?? false) && (a?.actors.length ?? 0) === 0, skill: OWNING_SKILL.actor, subWorkflow: 'edge cases table — add one actor after UCs / RBAC exist; full re-Write of the app doc', writeDiscipline: 'full-rewrite', machineBlocksHash: machineBlocksHashOf(a?.text ?? ''), } } case 'screen': { const doc = corpus.screenDocs.find((d) => d.depth === 1 && d.sectionFolder.toLowerCase() === (sectionFolder ?? '').toLowerCase()) return { file: `${app}/${module}/${sectionFolder ?? '
'}/screen.md`, exists: doc !== undefined, placeholder: doc !== undefined && !/^###\s+SCR-/m.test(doc.text), skill: OWNING_SKILL.screen, subWorkflow: '§ Screen code format + levels/-screens.md; full re-Write of the section doc', writeDiscipline: 'full-rewrite', machineBlocksHash: machineBlocksHashOf(doc?.text ?? ''), } } } } const emptyExisting = (): ExistingReport => ({ count: 0, exact: [], similar: [], crossApp: [] }) /** mode=impact. */ export function executeChangeImpact(spec: ChangeImpactInput, corpus: ScopeCorpus): ChangeImpactReport { const { scope } = corpus const sectionFolder = scope.section const blocked: Blocked[] = [] const warnings = [...corpus.warnings] const owner = ownerOf(spec, corpus, sectionFolder) if (spec.kind === 'business-rule' && sectionFolder && ownerRulesDoc(corpus, sectionFolder).fallback) { warnings.push( `${scope.app}/${scope.module}/${sectionFolder} has no règles-métier.md of its own — the module doc (${owner.file}) owns the rule; set its **Portée** to the section.`, ) } if (!IMPLEMENTED_KINDS.includes(spec.kind)) { blocked.push({ code: 'kind-not-implemented', reason: `kind=${spec.kind} has no checklist in this version — use the owning skill in ENRICH mode (re-list every existing item) and run the audits.`, routeTo: { skill: OWNING_SKILL[spec.kind] }, }) } if ((spec.kind === 'use-case' || spec.kind === 'screen') && !sectionFolder) { blocked.push({ code: 'section-required', reason: `kind=${spec.kind} is authored at the SECTION level — name the section folder.`, candidates: corpus.sectionFolders, }) } // Owner doc must exist for a change (a first pass is the owning skill's job). if (!owner.exists && blocked.length === 0) { blocked.push({ code: 'owner-doc-missing', reason: `${owner.file} does not exist — this is a FIRST PASS, not a change: run ${owner.skill} on the scope.`, routeTo: { skill: owner.skill }, }) } else if (owner.placeholder && blocked.length === 0) { blocked.push({ code: 'owner-doc-placeholder', reason: `${owner.file} is still a placeholder (no item authored) — run ${owner.skill} on the scope first.`, routeTo: { skill: owner.skill }, }) } let existing = emptyExisting() let allocation: Allocation | null = null let trace: TraceReport | undefined if (blocked.length === 0) { if (spec.kind === 'use-case') { existing = existingUseCases(corpus, sectionFolder!, { title: spec.target.title, code: spec.target.code }, spec.op) if (spec.target.actor) { const appActors = corpus.actors.find((a) => a.app.toLowerCase() === scope.app.toLowerCase())?.actors ?? [] const known = appActors.some( (a) => a.code.toLowerCase() === spec.target.actor!.toLowerCase() || a.label.toLowerCase() === spec.target.actor!.toLowerCase(), ) if (!known) { blocked.push({ code: 'actor-not-found', reason: `actor "${spec.target.actor}" is not in ${scope.app}/acteur.md (${appActors.map((a) => `${a.code} ${a.label}`).join(', ') || 'no actor'}) — a new role is its own change.`, routeTo: { skill: 'ba-change', kind: 'actor' }, candidates: appActors.map((a) => a.code), }) } } } else if (spec.kind === 'business-rule') { existing = existingRules(corpus, sectionFolder, { title: spec.target.title, code: spec.target.code }, spec.op) const wanted = (spec.target.useCase ?? '').split(/[,\s]+/).map((s) => s.trim()).filter(Boolean) const known = new Set(corpus.useCaseDocs.flatMap((d) => d.parsed.ucs.map((u) => u.ucCode.toLowerCase()))) const missing = wanted.filter((u) => !known.has(u.toLowerCase())) if (missing.length > 0) { blocked.push({ code: 'use-case-not-found', reason: `linked UC code(s) not found in ${scope.app}/${scope.module}: ${missing.join(', ')} — a rule never fabricates a reference.`, routeTo: { skill: 'ba-change', kind: 'use-case' }, candidates: [...known].sort(), }) } } else if (spec.kind === 'actor') { existing = existingActors(corpus, { title: spec.target.title, code: spec.target.code }, spec.op) } else if (spec.kind === 'permission') { existing = existingPermissions(corpus, { actor: spec.target.actor, permissionPath: spec.target.permissionPath, portee: spec.target.portee }, spec.op) const appLower = scope.app.toLowerCase() const appActors = corpus.actors.find((a) => a.app.toLowerCase() === appLower)?.actors ?? [] if (spec.target.actor) { const known = appActors.some( (a) => a.code.toLowerCase() === spec.target.actor!.toLowerCase() || a.label.toLowerCase() === spec.target.actor!.toLowerCase(), ) if (!known) { blocked.push({ code: 'actor-not-found', reason: `actor "${spec.target.actor}" is not in ${scope.app}/acteur.md (${appActors.map((a) => `${a.code} ${a.label}`).join(', ') || 'no actor'}) — a row never cites an unknown actor (RBAC-005).`, routeTo: { skill: 'ba-change', kind: 'actor' }, candidates: appActors.map((a) => a.code), }) } } const path = spec.target.permissionPath?.trim() if (path) { if (path.toLowerCase().startsWith(`${appLower}.`)) { blocked.push({ code: 'permission-path-app-prefixed', reason: `"${path}" starts with the application code — human rows are \`module.section[.resource].action\` WITHOUT the app prefix (create-rbac prohibition 10); write \`${path.slice(appLower.length + 1)}\`.`, routeTo: { skill: 'ba-change', kind: 'permission' }, }) } else { const parsed = parsePermissionPath(`${appLower}.${path}`) if (!parsed || parsed.grain === 'application') { blocked.push({ code: 'permission-path-invalid', reason: `"${path}" is not a valid human row path — expected \`module.section[.resource].\` with an action among access, read, create, update, delete, export, import, approve, reject, assign, execute, lookup, or the \`read.all\` tier on a section/resource (lib/permission-actions).`, routeTo: { skill: 'ba-change', kind: 'permission' }, }) } } } if (spec.target.portee !== undefined && canonicalPortee(spec.target.portee) === null) { blocked.push({ code: 'portee-unknown', reason: `Portée "${spec.target.portee}" is outside the closed vocabulary (RBAC-007): toutes|all, les siennes|own, attribuées|assigned, équipe|team, personnalisée|custom (+ filter clause).`, routeTo: { skill: 'ba-change', kind: 'permission' }, }) } } else if (spec.kind === 'attribute') { const entities = corpus.entities?.entities ?? [] const entity = entities.find((e) => e.name.toLowerCase() === (spec.target.entity ?? '').toLowerCase()) if (!entity) { blocked.push({ code: 'entity-not-found', reason: `entity "${spec.target.entity}" is not in ${scope.app}/${scope.module}/entité.md — a new entity is its own change.`, routeTo: { skill: 'ba-change', kind: 'entity' }, candidates: entities.map((e) => e.name), }) } else { existing = existingAttributes(corpus, spec.target.entity, spec.target.attribute, spec.op) trace = traceTerm(corpus, spec.target.attribute!) if (spec.op === 'add' && !trace.found) { blocked.push({ code: 'upstream-trace-missing', reason: `no use case or business rule of ${scope.app}/${scope.module} names « ${spec.target.attribute} » (${trace.docsScanned} doc(s) searched, every form of the name, accents folded) — the data model only carries what the upstream asks for (create-data-model prohibition 1). Write the UC or the rule first.`, routeTo: { skill: 'ba-change', kind: 'use-case' }, }) } } } else if (spec.kind === 'entity') { const name = spec.target.entity ?? spec.target.title ?? spec.target.code ?? '' existing = existingEntities(corpus, { title: spec.target.entity ?? spec.target.title, code: spec.target.code }, spec.op) if (spec.op === 'add') { trace = traceTerm(corpus, name) if (!trace.found) { blocked.push({ code: 'upstream-trace-missing', reason: `no use case or business rule of ${scope.app}/${scope.module} names « ${name} » (${trace.docsScanned} doc(s) searched) — an entity with no verbatim trace is a hallucination the audits err on (DM-011). Write the UC or the rule first.`, routeTo: { skill: 'ba-change', kind: 'use-case' }, }) } const core = matchCoreEntity(name) if (core) { blocked.push({ code: 'core-entity-collision', reason: `« ${name} » is the SmartStack Core entity ${core.name} (${core.qualifiedTable}) — never re-modelled in a client module: reference it through a \`scope core\` Relations entry (CODE-001 / DM-018).`, routeTo: { skill: 'ba-create-data-model' }, }) } const reserved = matchReservedCoreName(name) if (reserved) { blocked.push({ code: 'core-reserved-name', reason: `« ${name} » is a platform-reserved name (${reserved.name}) — use instead: ${reserved.useInstead}.`, routeTo: { skill: 'ba-create-data-model' }, }) } const capability = matchCapabilityTrigger(name) if (capability) { warnings.push(`« ${name} » looks like the platform capability « ${capability.key} » — steer to the platform service instead of modelling it (DM-019 / CODE-006); the playbook challenges it.`) } } } else if (spec.kind === 'screen') { existing = existingScreens( corpus, sectionFolder!, { title: spec.target.title, code: spec.target.code, entity: spec.target.entity, screenType: spec.target.screenType, mode: spec.target.mode }, spec.op, ) const entities = corpus.entities?.entities ?? [] if (spec.target.entity && !entities.some((e) => e.name.toLowerCase() === spec.target.entity!.toLowerCase())) { blocked.push({ code: 'entity-not-found', reason: `entity "${spec.target.entity}" is not in ${scope.app}/${scope.module}/entité.md — a screen binds an entity that exists (XD-005).`, routeTo: { skill: 'ba-change', kind: 'entity' }, candidates: entities.map((e) => e.name), }) } if (spec.target.screenType && !(SCREEN_TYPES as readonly string[]).includes(spec.target.screenType)) { blocked.push({ code: 'screen-type-unknown', reason: `"${spec.target.screenType}" is not a SmartComponent type — expected one of ${SCREEN_TYPES.join(', ')}.`, routeTo: { skill: 'ba-change', kind: 'screen' }, }) } const permission = spec.target.permissionPath?.trim() if (permission) { const floor = FLOOR_BY_GRAIN.section.map((a) => `${scope.module.toLowerCase()}.${sectionFolder!}.${a}`) const known = corpus.rbac.rows.some((r) => r.path.toLowerCase() === permission.toLowerCase()) || floor.includes(permission.toLowerCase()) if (!known) { blocked.push({ code: 'permission-not-found', reason: `"${permission}" is neither a human row of rbac.md nor a floor path of the section (${floor.join(', ')}) — a screen guarded by a permission nobody seeds is a 403 at runtime.`, routeTo: { skill: 'ba-change', kind: 'permission' }, candidates: [...new Set([...corpus.rbac.rows.map((r) => r.path), ...floor])].sort(), }) } } const wanted = (spec.target.useCase ?? '').split(/[,\s]+/).map((s) => s.trim()).filter(Boolean) const known = new Set(corpus.useCaseDocs.flatMap((d) => d.parsed.ucs.map((u) => u.ucCode.toLowerCase()))) const missing = wanted.filter((u) => !known.has(u.toLowerCase())) if (missing.length > 0) { blocked.push({ code: 'use-case-not-found', reason: `linked UC code(s) not found in ${scope.app}/${scope.module}: ${missing.join(', ')} — a screen never cites a use case that does not exist (SCR-005).`, routeTo: { skill: 'ba-change', kind: 'use-case' }, candidates: [...known].sort(), }) } } if (spec.op === 'modify' && !existing.resolved) { blocked.push({ code: 'target-not-found', reason: `no ${spec.kind} with code "${spec.target.code ?? ''}" in ${owner.file}${spec.kind === 'business-rule' ? ' (nor in a sibling rules doc of the module)' : ''}.`, routeTo: { skill: 'ba-change' }, }) } } if (blocked.length === 0 && spec.op === 'add') { allocation = spec.kind === 'use-case' ? allocateUseCase(corpus, sectionFolder!, spec.reserve) : spec.kind === 'business-rule' ? allocateRule(corpus, spec.reserve) : spec.kind === 'actor' ? allocateActor(corpus, spec.reserve) : spec.kind === 'entity' ? allocateEntity(corpus, spec.reserve) : spec.kind === 'screen' ? allocateScreen(corpus, sectionFolder!, spec.reserve) : null } const impact = blocked.length === 0 ? buildImpact({ corpus, kind: spec.kind, op: spec.op, target: spec.target, ...(sectionFolder ? { sectionFolder } : {}), allocation, ...(existing.resolved ? { resolved: existing.resolved } : {}), existing, ...(trace ? { trace } : {}), ownerFile: owner.file, }) : [] if (existing.exact.length > 0) { warnings.push( `${existing.exact.length} existing item(s) with the same title/identity — reuse or modify them instead of adding a duplicate: ${existing.exact.map((m) => m.code ?? m.title).join(', ')}.`, ) } if (existing.crossApp.length > 0) { warnings.push( `${existing.crossApp.length} look-alike(s) in OTHER applications (reported, never written): ${existing.crossApp.map((m) => `${m.code} (${m.file})`).join(', ')}.`, ) } return { mode: 'impact', kind: spec.kind, op: spec.op, scope: { app: scope.app, module: scope.module, ...(sectionFolder ? { section: sectionFolder } : {}) }, state: corpus.state, owner, allocation, existing, ...(trace ? { trace } : {}), impact, blocked, warnings, } } /** The block of ONE item: from its `### CODE` heading to the next `#`-heading or EOF. */ function itemBlock(text: string, code: string): string | null { const lines = text.split(/\r?\n/) const start = lines.findIndex((l) => /^###\s+/.test(l) && l.toLowerCase().includes(code.toLowerCase())) if (start < 0) return null let end = start + 1 while (end < lines.length && !/^#{1,3}\s/.test(lines[end]!)) end++ return lines.slice(start, end).join('\n') } function sourcesCheck(corpus: ScopeCorpus, block: string | null): SourcesCheck { if (!corpus.state.sourcesPresent) return 'not-required' if (!block) return 'missing' const line = block.split(/\r?\n/).find((l) => /^-\s*\*\*Sources\*\*\s*:/.test(l)) if (!line) return 'missing' const registry = loadSourcesRegistry(sourcesRootFor(corpus.scope.baRoot)) const citations = citationsInText(line, 'sources-line') if (citations.length === 0) return 'missing' return citations.every((c) => resolveCitation(registry, c) === 'ok') ? 'ok' : 'unresolved' } /** mode=verify — re-parse the owner doc AFTER the skill's Write. */ export function executeVerify(spec: ChangeImpactInput, corpus: ScopeCorpus): ChangeImpactReport { const { scope } = corpus const sectionFolder = scope.section const owner = ownerOf(spec, corpus, sectionFolder) const v = spec.verify! const warnings = [...corpus.warnings] const blocked: Blocked[] = [] let count = 0 let found = false const duplicates: string[] = [] const lost: string[] = [] let text = '' if (spec.kind === 'use-case') { const doc = sectionFolder ? sectionUseCaseDoc(corpus, sectionFolder) : undefined if (doc) { text = doc.text const parsed = parseUseCases(doc.text, doc.relPath) count = parsed.ucs.length found = parsed.ucs.some((u) => u.ucCode.toLowerCase() === v.expectCode.toLowerCase()) lost.push(...parsed.lost) const seen = new Set() for (const u of parsed.ucs) { const k = u.ucCode.toLowerCase() if (seen.has(k)) duplicates.push(u.ucCode) seen.add(k) } for (const line of doc.text.split(/\r?\n/)) { if (/^###\s+uc-/i.test(line) && !UC_HEADING_RE.test(line)) lost.push(`near-miss heading: ${line.trim()}`) } } else { blocked.push({ code: 'owner-doc-missing', reason: `${owner.file} not found after the Write.` }) } } else if (spec.kind === 'business-rule') { const doc = ownerRulesDoc(corpus, sectionFolder).doc if (doc) { text = doc.text const parsed = parseRules(doc.text) count = parsed.rules.length found = parsed.rules.some((r) => r.code.toLowerCase() === v.expectCode.toLowerCase()) const seen = new Set() for (const r of parsed.rules) { if (seen.has(r.code)) duplicates.push(r.code) seen.add(r.code) } for (const w of parsed.warnings) if (/non-canonical|unparsable/i.test(w)) lost.push(w) for (const line of doc.text.split(/\r?\n/)) { if (/^###\s+br-/i.test(line) && !RULE_HEADING_RE.test(line)) lost.push(`near-miss heading: ${line.trim()}`) } } else { blocked.push({ code: 'owner-doc-missing', reason: `${owner.file} not found after the Write.` }) } } else if (spec.kind === 'actor') { const a = corpus.actors.find((x) => x.app.toLowerCase() === scope.app.toLowerCase()) if (a?.exists) { text = a.text const parsed = parseActors(text) count = parsed.actors.length found = parsed.actors.some((x) => x.code.toLowerCase() === v.expectCode.toLowerCase()) const seen = new Set() for (const x of parsed.actors) { if (seen.has(x.code)) duplicates.push(x.code) seen.add(x.code) } for (const line of text.split(/\r?\n/)) { if (/^###\s+ba-/i.test(line) && !ACTOR_HEADING_RE.test(line)) lost.push(`near-miss heading: ${line.trim()}`) } } else { blocked.push({ code: 'owner-doc-missing', reason: `${owner.file} not found after the Write.` }) } } else if (spec.kind === 'permission') { if (corpus.rbac.exists) { text = corpus.rbac.text const rows = parseRbacRows(text) count = rows.length const actor = (spec.target.actor ?? '').toLowerCase() const path = v.expectCode.toLowerCase() found = rows.some( (r) => (r.actorCode.toLowerCase() === actor || (r.actorLabel ?? '').toLowerCase() === actor) && r.path.toLowerCase() === path, ) const seen = new Set() for (const r of rows) { const key = `${r.actorCode.toLowerCase()}|${r.path.toLowerCase()}` if (seen.has(key)) duplicates.push(`${r.actorCode} · ${r.path}`) seen.add(key) } // A line that starts like a human row but does not parse is a row outside the matrix. const rowRe = new RegExp(ROW_RE.source) for (const line of text.split(/\r?\n/)) { if (/^\|\s*BA-/i.test(line) && !rowRe.test(line)) lost.push(`near-miss row: ${line.trim()}`) } } else { blocked.push({ code: 'owner-doc-missing', reason: `${owner.file} not found after the Write.` }) } } else if (spec.kind === 'attribute' || spec.kind === 'entity') { if (corpus.entityDoc) { text = corpus.entityDoc.text const parsed = parseEntityDoc(text, `${scope.app}/${scope.module}`) for (const w of parsed.warnings) if (isAttributeRowLoss(w) || isIndexLoss(w) || relationLossOf(w) > 0) lost.push(w) for (const line of text.split(/\r?\n/)) { if (ENT_NEAR_MISS_RE.test(line) && !ENT_HEADING_RE.test(line)) lost.push(`near-miss heading: ${line.trim()}`) } if (spec.kind === 'entity') { count = parsed.entities.length found = parsed.entities.some((e) => e.code.toLowerCase() === v.expectCode.toLowerCase() || e.name.toLowerCase() === v.expectCode.toLowerCase()) const seen = new Set() for (const e of parsed.entities) { if (seen.has(e.code)) duplicates.push(e.code) seen.add(e.code) } } else { const entity = parsed.entities.find((e) => e.name.toLowerCase() === (spec.target.entity ?? '').toLowerCase()) if (entity) { const wanted = v.expectCode.toLowerCase() count = entity.attributes.length found = entity.attributes.some((a) => a.name.toLowerCase() === wanted) const seen = new Set() for (const a of entity.attributes) { const k = a.name.toLowerCase() if (seen.has(k)) duplicates.push(`${entity.name}.${a.name}`) seen.add(k) } } else { blocked.push({ code: 'entity-not-found', reason: `entity "${spec.target.entity}" not found in ${owner.file} after the Write — was its heading dropped by the re-Write?` }) } } } else { blocked.push({ code: 'owner-doc-missing', reason: `${owner.file} not found after the Write.` }) } } else if (spec.kind === 'screen') { const doc = corpus.screenDocs.find((d) => d.depth === 1 && d.sectionFolder.toLowerCase() === (sectionFolder ?? '').toLowerCase()) if (doc) { text = doc.text const parsed = parseScreenFile(text, { file: `${scope.app}/${scope.module}/${doc.relPath}`, app: scope.app, module: scope.module, section: sectionFolder ?? '' }) count = parsed.screens.length found = parsed.screens.some((s) => s.code.toLowerCase() === v.expectCode.toLowerCase()) const seen = new Set() for (const s of parsed.screens) { const k = s.code.toLowerCase() if (seen.has(k)) duplicates.push(s.code) seen.add(k) } const headingRe = new RegExp(SCREEN_HEADING_RE.source) for (const line of text.split(/\r?\n/)) { if (/^###\s+scr-/i.test(line) && !headingRe.test(line)) lost.push(`near-miss heading (no « (SmartType) » suffix?): ${line.trim()}`) } for (const w of parsed.warnings) if (/malformed|unreadable|dropped/i.test(w)) lost.push(w) } else { blocked.push({ code: 'owner-doc-missing', reason: `${owner.file} not found after the Write.` }) } } else { blocked.push({ code: 'kind-not-implemented', reason: `verify for kind=${spec.kind} is not implemented in this version.`, routeTo: { skill: OWNING_SKILL[spec.kind] } }) } const machineBlocksIntact = v.machineBlocksHash ? machineBlocksHashOf(text) === v.machineBlocksHash : null // A permission row and an attribute row have no block of their own — the sources requirement sits on the item that owns them. const sourcesCited = blocked.length === 0 && spec.kind !== 'permission' && spec.kind !== 'attribute' ? sourcesCheck(corpus, itemBlock(text, v.expectCode)) : 'not-required' const delta = count - v.baselineCount const expectedDelta = spec.op === 'add' ? 1 : 0 const ok = blocked.length === 0 && found && delta === expectedDelta && duplicates.length === 0 && lost.length === 0 && machineBlocksIntact !== false && (sourcesCited === 'ok' || sourcesCited === 'not-required') const verify: VerifyReport = { found, count, delta, duplicates, lost, machineBlocksIntact, sourcesCited, ok } if (!found) warnings.push(`${v.expectCode} is NOT in ${owner.file} after the Write.`) if (delta !== expectedDelta && blocked.length === 0) { warnings.push(`item count moved by ${delta} (expected ${expectedDelta}) — ${delta < expectedDelta ? 'an existing item was DROPPED by the full re-Write' : 'more items than expected were added'}.`) } if (machineBlocksIntact === false) warnings.push('a machine block (``) changed — re-emit it byte-identical.') if (sourcesCited === 'missing') warnings.push(`the sources registry exists but ${v.expectCode} carries no resolvable \`- **Sources** : SRC-NNN §n\` line.`) if (sourcesCited === 'unresolved') warnings.push(`a source citation of ${v.expectCode} does not resolve in the registry.`) return { mode: 'verify', kind: spec.kind, op: spec.op, scope: { app: scope.app, module: scope.module, ...(sectionFolder ? { section: sectionFolder } : {}) }, state: corpus.state, owner, allocation: null, existing: emptyExisting(), impact: [], blocked, verify, warnings, } }