From 15470f9e1f8e52ae1a4976ae2472e27c8275929b Mon Sep 17 00:00:00 2001 From: leduy Date: Fri, 11 Sep 2026 17:00:05 +0700 Subject: [PATCH 1/2] feat: filter per-unit consumes by Unit kind with consumes[].kinds A per-unit stage can now mark one consume as applying to some Unit kinds only, the consumer-side twin of produces_kinds. The directive builder (resolveConsumes) drops the consume for a Unit of another kind and the upstream-coverage dispatcher stops threading it, so a backend Unit is no longer asked to cite a UI mockup it never read. No list, or an untagged Unit, keeps the consume. Schema: `kinds` is a non-empty inline list of UNIT_KINDS and is refused on a stage that is not `for_each: unit-of-work`, where it would be inert. The frontmatter parser accepts an inline list on a consume subkey, the emitter writes it back, the plugin contribution regex tolerates it, and graph compile carries it into stage-graph.json. No core stage declares `kinds` yet; #1003 can tag wireframes / mockups / interaction-spec once this lands. Refs #999, #1003 --- .../protocols/stage-definition.md | 1 + core/tools/aidlc-graph.ts | 4 + core/tools/aidlc-lib.ts | 22 +- core/tools/aidlc-orchestrate.ts | 2 + core/tools/aidlc-plugin.ts | 4 +- core/tools/aidlc-sensor.ts | 24 ++ core/tools/aidlc-stage-schema.ts | 22 ++ docs/reference/15-stage-definition.md | 29 ++ tests/.coverage-ratchet.json | 2 +- tests/.coverage-registry.json | 34 +- tests/unit/gen-coverage-registry.test.ts | 1 + tests/unit/t338-consumes-kinds.test.ts | 322 ++++++++++++++++++ 12 files changed, 459 insertions(+), 8 deletions(-) create mode 100644 tests/unit/t338-consumes-kinds.test.ts diff --git a/core/aidlc-common/protocols/stage-definition.md b/core/aidlc-common/protocols/stage-definition.md index d4d164c19c..36477d0588 100644 --- a/core/aidlc-common/protocols/stage-definition.md +++ b/core/aidlc-common/protocols/stage-definition.md @@ -63,6 +63,7 @@ copies this table verbatim. | `consumes[].artifact` | string | yes per entry | lowercase-kebab | | `consumes[].required` | boolean | yes per entry | Scoped to the active plan. `true` means "if the producing stage runs, this consume must be satisfied" — not a global assertion that the artifact always exists. Scopes that skip the producer (e.g., `bugfix` skipping `units-generation`) make the consume moot; the stage body handles graceful degradation. The reserved `when:` primitive will eventually let authors express richer predicates | | `consumes[].conditional_on` | string | optional | `brownfield` \| `greenfield`. Omit for unconditional consumes — no `always` value | +| `consumes[].kinds` | string[] | optional | Inline list of Unit kinds (`service` \| `spec` \| `ui` \| `packaging` \| `library`) this consume applies to; only valid on a `for_each: unit-of-work` stage. Omit for every kind. A Unit whose kind is not listed does not receive the consume in its directive and is not asked to reference it by `upstream-coverage`; an untagged Unit keeps it. The consumer-side twin of `produces_kinds` | | `requires_stage` | string[] | yes | empty allowed; each entry a known stage slug. Two roles: (1) semantic data dependency; (2) presentation-order edge for stages with no semantic link but a fixed display order. Primary input to computed `display_order` | | `scopes` | string[] | optional | each entry a scope name with a matching `{{HARNESS_DIR}}/scopes/aidlc-.md` file. Naming a scope marks this stage EXECUTE under that scope; absence marks it SKIP. The per-stage transpose of the scope membership matrix — `aidlc-graph compile` reads every stage's `scopes:` and emits the compiled EXECUTE/SKIP grid (`tools/data/scope-grid.json`). The 3 initialization stages name all scopes (always EXECUTE). Absent and `[]` are treated identically | | `inputs` | string | yes | human prose (preserves today's `**Inputs**:` line) | diff --git a/core/tools/aidlc-graph.ts b/core/tools/aidlc-graph.ts index 2c57e1928b..d60bbce083 100644 --- a/core/tools/aidlc-graph.ts +++ b/core/tools/aidlc-graph.ts @@ -126,6 +126,7 @@ export interface Consume { artifact: string; required: boolean; conditional_on?: "brownfield" | "greenfield"; + kinds?: string[]; } // Per-rule resolution row baked into each stage's rules_in_context. @@ -2831,6 +2832,9 @@ function buildGraphStage( if (c.conditional_on !== undefined) { out.conditional_on = c.conditional_on; } + if (c.kinds !== undefined) { + out.kinds = c.kinds; + } return out; }); diff --git a/core/tools/aidlc-lib.ts b/core/tools/aidlc-lib.ts index 380a58ae11..270f2acf23 100644 --- a/core/tools/aidlc-lib.ts +++ b/core/tools/aidlc-lib.ts @@ -146,7 +146,7 @@ export interface StageEntry { // unit whose kind is not in its list (both directive paths and coverage). // Absent map = full matrix (every produces entry applies to every unit). produces_kinds?: Record; - consumes?: Array<{ artifact: string; required: boolean; conditional_on?: string }>; + consumes?: Array<{ artifact: string; required: boolean; conditional_on?: string; kinds?: string[] }>; requires_stage?: string[]; scopes?: string[]; inputs?: string; @@ -32325,6 +32325,9 @@ export function emitStageFrontmatter(obj: Record): string { if (typeof e.conditional_on === "string") { lines.push(` conditional_on: ${emitScalar(e.conditional_on)}`); } + if (Array.isArray(e.kinds)) { + lines.push(` kinds: [${e.kinds.join(", ")}]`); + } } } } else if (Array.isArray(v)) { @@ -32458,7 +32461,11 @@ function objectListField( current = {}; current[itemMatch[1]] = coerceScalar(itemMatch[2]); } else if (subMatch && current) { - current[subMatch[1]] = coerceScalar(subMatch[2]); + const value = subMatch[2]; + current[subMatch[1]] = + value.startsWith("[") && value.endsWith("]") + ? parseInlineDepsList(value) + : coerceScalar(value); } else { throw new Error( `Malformed ${key}[] entry in frontmatter: ${line.trim()}` @@ -36290,6 +36297,17 @@ export function filterProducesByKind( }); } +// The consumer-side twin of filterProducesByKind: a consume with a `kinds` +// list applies only to a unit of one of those kinds. No list, or an untagged +// unit (`unitKind` null), keeps the consume. +export function consumeAppliesToKind( + consume: { kinds?: string[] }, + unitKind: string | null +): boolean { + if (unitKind === null || consume.kinds === undefined) return true; + return consume.kinds.includes(unitKind); +} + // ----------------------------------------------------------------------------- // State-schema-version classification (shared by runtime + doctor) // ----------------------------------------------------------------------------- diff --git a/core/tools/aidlc-orchestrate.ts b/core/tools/aidlc-orchestrate.ts index ff73cd3d2c..25e5e822b6 100644 --- a/core/tools/aidlc-orchestrate.ts +++ b/core/tools/aidlc-orchestrate.ts @@ -156,6 +156,7 @@ import { effectivePlanAction, errorMessage, evaluateGuardRefusal, + consumeAppliesToKind, filterProducesByKind, firstInScopeStageOfPhase, firstPlannedStageOfPhase, @@ -3740,6 +3741,7 @@ function resolveConsumes( ) { continue; } + if (!consumeAppliesToKind(consume, unitKind)) continue; const producer = producersOf(consume.artifact)[0]; if ( producer && diff --git a/core/tools/aidlc-plugin.ts b/core/tools/aidlc-plugin.ts index e6bed737cc..6576229025 100644 --- a/core/tools/aidlc-plugin.ts +++ b/core/tools/aidlc-plugin.ts @@ -1020,10 +1020,10 @@ function removeListValues( } function removeConsumes(content: string, artifacts: ReadonlySet): string { - const block = /^consumes:\n((?: {2}- artifact:.*\n(?: {4}(?:required|conditional_on):.*\n)*)*)/m; + const block = /^consumes:\n((?: {2}- artifact:.*\n(?: {4}(?:required|conditional_on|kinds):.*\n)*)*)/m; const match = content.match(block); if (!match) return content; - const kept = [...match[1].matchAll(/^ {2}- artifact:\s*([\w-]+).*\n(?: {4}(?:required|conditional_on):.*\n)*/gm)] + const kept = [...match[1].matchAll(/^ {2}- artifact:\s*([\w-]+).*\n(?: {4}(?:required|conditional_on|kinds):.*\n)*/gm)] .filter((entry) => !artifacts.has(entry[1])) .map((entry) => entry[0]); return content.replace(block, kept.length > 0 ? `consumes:\n${kept.join("")}` : "consumes: []\n"); diff --git a/core/tools/aidlc-sensor.ts b/core/tools/aidlc-sensor.ts index ccc7f96ccc..0474994174 100644 --- a/core/tools/aidlc-sensor.ts +++ b/core/tools/aidlc-sensor.ts @@ -61,6 +61,7 @@ import { artifactFilename, auditLockDir, codekbDir, + consumeAppliesToKind, errorMessage, getField, holdsAuditLock, @@ -70,6 +71,7 @@ import { readRegularFileNoFollowOrThrow, readStateFile, recordDir, + resolveBoltDag, resolveProjectDir, sensorsDir, usesStageLevelPerUnitArtifacts, @@ -471,6 +473,26 @@ function artifactDirsForProducer( return [join(rec, producer.phase, producer.slug)]; } +// Unit kind for a per-unit output, from its `/construction///` +// path and the unit DAG; null (keep every consume) when either is absent. +function unitKindForOutput( + pd: string, + stage: { slug: string; for_each?: string }, + outputPath: string, +): string | null { + if (stage.for_each !== "unit-of-work") return null; + const rec = recordDir(pd); + if (rec === null) return null; + const norm = outputPath.replace(/\\/g, "/"); + const marker = `${rec.replace(/\\/g, "/")}/construction/`; + if (!norm.startsWith(marker)) return null; + const rest = norm.slice(marker.length).split("/"); + if (rest.length < 3 || rest[1] !== stage.slug) return null; + const dag = resolveBoltDag(pd); + if (dag.state !== "ok") return null; + return dag.unitKinds?.get(rest[0]) ?? null; +} + function presentConsumes(pd: string, slugs: string[]): string[] { if (recordDir(pd) === null) return slugs; return slugs.filter((name) => { @@ -588,7 +610,9 @@ function handleFire(args: string[]): void { scriptArgs.push("--output-path", outputPath); } if (id === "upstream-coverage") { + const unitKind = unitKindForOutput(projectDir, stageNode, outputPath); const consumeSlugs = (stageNode.consumes ?? []) + .filter((c) => consumeAppliesToKind(c, unitKind)) .map((c) => c.artifact) .filter((a) => typeof a === "string" && a.length > 0); scriptArgs.push( diff --git a/core/tools/aidlc-stage-schema.ts b/core/tools/aidlc-stage-schema.ts index e6c268db04..39655f6b0b 100644 --- a/core/tools/aidlc-stage-schema.ts +++ b/core/tools/aidlc-stage-schema.ts @@ -64,6 +64,11 @@ export interface StageFrontmatter { artifact: string; required: boolean; conditional_on?: "brownfield" | "greenfield"; + // kinds - optional per-kind applicability of ONE consume on a per-unit + // stage: the unit kinds (UNIT_KINDS) this input applies to. Absent = every + // kind. Mirrors produces_kinds on the consumer side, so a once-per-workflow + // artifact (a UI mockup) can be an input of `ui` units only. + kinds?: string[]; }>; requires_stage: string[]; sensors?: string[]; @@ -577,6 +582,23 @@ export function validateStageFrontmatter( ); } } + + // kinds - only a per-unit stage has a unit kind to filter on; anywhere + // else the list would be silently inert, so it is rejected outright. + if ("kinds" in e && e.kinds !== undefined) { + if (o.for_each !== "unit-of-work") { + errors.push(`consumes[${i}].kinds requires for_each: unit-of-work`); + } + if (!Array.isArray(e.kinds) || e.kinds.length === 0) { + errors.push(`consumes[${i}].kinds must be a non-empty list of unit kinds`); + } else { + for (const k of e.kinds) { + if (typeof k !== "string" || !(UNIT_KINDS as readonly string[]).includes(k)) { + errors.push(`consumes[${i}].kinds lists unknown kind "${typeof k === "string" ? k : describe(k)}"`); + } + } + } + } }); } } diff --git a/docs/reference/15-stage-definition.md b/docs/reference/15-stage-definition.md index 4a6e857ca2..d31dbfd9c6 100644 --- a/docs/reference/15-stage-definition.md +++ b/docs/reference/15-stage-definition.md @@ -327,6 +327,35 @@ For unconditional consumes, **omit the field entirely**. There is no `always` value — an unconditional consume simply has no `conditional_on` key. +### `consumes[].kinds` + +Optional inline list on one consume of a `for_each: unit-of-work` stage: the +Unit kinds (`service | spec | ui | packaging | library`, the same set +`produces_kinds` uses) that input applies to. Omit it for an input every Unit +reads. + +```yaml +for_each: unit-of-work +consumes: + - artifact: functional-spec + required: true + - artifact: mockups + required: false + kinds: [ui] +``` + +Why it exists: `produces_kinds` prunes what a Unit must *write*, but a consume +had no kind axis. A once-per-workflow artifact such as a UI mockup is a real +input of `ui` Units and noise for a `service` Unit — and `upstream-coverage` +threads every consume whose file exists, so the backend Unit's deliverables +were asked to reference mockups they never read, a false `SENSOR_FAILED` on +correct output. With `kinds`, the directive builder (`resolveConsumes`) and the +sensor dispatcher apply the same rule: a Unit whose kind is not listed does not +see the consume; an untagged Unit (no `kind` in the DAG) keeps it, exactly as +`produces_kinds` keeps the full matrix for an untagged Unit. Declaring `kinds` +on a stage that is not per-unit is a schema error, since there would be no +Unit kind to filter on and the list would be silently inert. + ### `optional_produces` A plain kebab-case string list, parallel to `produces:`. It names artifacts diff --git a/tests/.coverage-ratchet.json b/tests/.coverage-ratchet.json index d2eb694fea..45cbd2c1e3 100644 --- a/tests/.coverage-ratchet.json +++ b/tests/.coverage-ratchet.json @@ -1,7 +1,7 @@ { "note": "Committed baseline: covered-unit count per class. The --check ratchet fails CI if any class's covered count DROPS below these numbers without a reviewed deferred entry. Monotonic anti-regression: you can cover more, never silently less. Regenerate with: bun tests/gen-coverage-registry.ts", "coveredByClass": { - "function": 579, + "function": 580, "audit": 70, "scope": 11, "stage": 11, diff --git a/tests/.coverage-registry.json b/tests/.coverage-registry.json index 862cd3d21a..1c7095a089 100644 --- a/tests/.coverage-registry.json +++ b/tests/.coverage-registry.json @@ -20,9 +20,9 @@ "render-surface": "tui" }, "counts": { - "total": 1275, + "total": 1276, "enumeratedByClass": { - "function": 943, + "function": 944, "audit": 110, "scope": 11, "stage": 33, @@ -31,7 +31,7 @@ "render-surface": 7 }, "coveredByClass": { - "function": 579, + "function": 580, "audit": 70, "scope": 11, "stage": 11, @@ -2833,6 +2833,18 @@ "coveredBy": [], "status": "UNCOVERED" }, + { + "unitClass": "function", + "unitId": "function:consumeAppliesToKind", + "minMechanism": "none", + "coveredBy": [ + { + "file": "tests/unit/t338-consumes-kinds.test.ts", + "mechanism": "cli" + } + ], + "status": "covered" + }, { "unitClass": "function", "unitId": "function:consumeCopilotConversation", @@ -3341,6 +3353,10 @@ "file": "tests/unit/t207-unit-kind-schema.test.ts", "mechanism": "cli" }, + { + "file": "tests/unit/t338-consumes-kinds.test.ts", + "mechanism": "cli" + }, { "file": "tests/unit/t64.test.ts", "mechanism": "none" @@ -6644,6 +6660,10 @@ "file": "tests/unit/t207-unit-kind-schema.test.ts", "mechanism": "cli" }, + { + "file": "tests/unit/t338-consumes-kinds.test.ts", + "mechanism": "cli" + }, { "file": "tests/unit/t64.test.ts", "mechanism": "none" @@ -13695,6 +13715,10 @@ "file": "tests/unit/t338-ceremony-verb.test.ts", "mechanism": "cli" }, + { + "file": "tests/unit/t338-consumes-kinds.test.ts", + "mechanism": "cli" + }, { "file": "tests/unit/t340-default-scope-resolver.test.ts", "mechanism": "cli" @@ -13955,6 +13979,10 @@ { "file": "tests/integration/t92.test.ts", "mechanism": "cli" + }, + { + "file": "tests/unit/t338-consumes-kinds.test.ts", + "mechanism": "cli" } ], "status": "covered" diff --git a/tests/unit/gen-coverage-registry.test.ts b/tests/unit/gen-coverage-registry.test.ts index 9fc9e386ad..b07ef3ac03 100644 --- a/tests/unit/gen-coverage-registry.test.ts +++ b/tests/unit/gen-coverage-registry.test.ts @@ -947,6 +947,7 @@ describe("mechanismsOf is body-derived (milestone 3)", () => { // t337 spawns the shipped doctor to pin the "Workspace source boundary // binds" row, which reads a real source walk against a real workspace. "unit/t337-source-boundary-reason.test.ts", + "unit/t338-consumes-kinds.test.ts", "unit/t339-construction-autonomy-gates.test.ts", "unit/t340-grouped-plan-approval.test.ts", "unit/t340-kiro-ide-ignore-sources-doctor.test.ts", diff --git a/tests/unit/t338-consumes-kinds.test.ts b/tests/unit/t338-consumes-kinds.test.ts new file mode 100644 index 0000000000..11d94dddd6 --- /dev/null +++ b/tests/unit/t338-consumes-kinds.test.ts @@ -0,0 +1,322 @@ +// covers: function:parseStageFrontmatter, function:emitStageFrontmatter, function:validateStageFrontmatter, function:consumeAppliesToKind, subcommand:aidlc-orchestrate:next, subcommand:aidlc-sensor:fire +// +// t338 - `consumes[].kinds`: the consumer-side twin of produces_kinds. A +// per-unit stage may mark one consume as applying to some unit kinds only; the +// directive builder drops it for a unit of another kind and the upstream- +// coverage dispatcher stops threading it, so a backend unit is never asked to +// cite a UI mockup it never read. Absent list, or an untagged unit, keeps the +// consume - the same fail-open posture filterProducesByKind has. +// +// Four surfaces, one file: the frontmatter parse/emit round-trip and the schema +// validator (in-process, shipped bytes), then the engine `next` directive and +// the sensor `fire` dispatcher (cli, spawned). Both spawned cases point +// AIDLC_STAGE_GRAPH at a copy of the shipped graph with ONE consume added to +// functional-design - `wireframes` gated to [ui] - since no core stage declares +// `kinds` yet; the stage file is not edited. +import { afterEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + AIDLC_SRC, + cleanupTestProject, + createTestProject, + DEFAULT_RECORD_DIR, + DEFAULT_SPACE, + resetAidlcEnv, + runOrchestrateNext, + seedAidlcMemory, + seedBoltDag, + seededRecordDir, + seededStateFile, + toPortablePath, +} from "../harness/fixtures.ts"; +import { + consumeAppliesToKind, + emitStageFrontmatter, + parseStageFrontmatter, +} from "../../dist/claude/.claude/tools/aidlc-lib.ts"; +import { validateStageFrontmatter } from "../../dist/claude/.claude/tools/aidlc-stage-schema.ts"; + +resetAidlcEnv(); + +const BUN = process.execPath; +const ORCH = join(AIDLC_SRC, "tools", "aidlc-orchestrate.ts"); +const SENSOR = join(AIDLC_SRC, "tools", "aidlc-sensor.ts"); +const SHIPPED_GRAPH = join(AIDLC_SRC, "tools", "data", "stage-graph.json"); +const RP = `aidlc/spaces/${DEFAULT_SPACE}/intents/${DEFAULT_RECORD_DIR}`; +const WIREFRAMES = `${RP}/ideation/rough-mockups/wireframes.md`; + +const tempDirs: string[] = []; +afterEach(() => { + while (tempDirs.length) { + const d = tempDirs.pop(); + if (d) { + try { + cleanupTestProject(d); + } catch { + rmSync(d, { recursive: true, force: true }); + } + } + } +}); + +// --------------------------------------------------------------------------- +// parse / emit / validate +// --------------------------------------------------------------------------- + +const STAGE = [ + "---", + "slug: demo-stage", + "phase: construction", + "execution: CONDITIONAL", + "condition: demo", + "lead_agent: aidlc-architect-agent", + "support_agents: []", + "mode: inline", + "for_each: unit-of-work", + "produces:", + " - alpha-doc", + "consumes:", + " - artifact: requirements", + " required: true", + " - artifact: mockups", + " required: false", + " kinds: [ui, spec]", + "requires_stage: []", + "inputs: x", + "outputs: y", + "---", + "", + "# Demo", +].join("\n"); + +function validate(overrides: Record) { + const base = { + slug: "demo-stage", + phase: "construction", + execution: "CONDITIONAL", + condition: "demo", + lead_agent: "aidlc-architect-agent", + support_agents: [], + mode: "inline", + for_each: "unit-of-work", + produces: ["alpha-doc"], + consumes: [], + requires_stage: [], + inputs: "x", + outputs: "y", + }; + return validateStageFrontmatter({ ...base, ...overrides }); +} + +describe("t338 consumes[].kinds - parse, emit, validate", () => { + test("parseStageFrontmatter: an inline kinds list parses onto the consume", () => { + const parsed = parseStageFrontmatter(STAGE); + expect(parsed.consumes).toEqual([ + { artifact: "requirements", required: true }, + { artifact: "mockups", required: false, kinds: ["ui", "spec"] }, + ]); + }); + + test("parse -> emit -> parse round-trips kinds and emits the inline form", () => { + const parsed = parseStageFrontmatter(STAGE); + const emitted = emitStageFrontmatter(parsed); + expect(emitted).toContain(" - artifact: mockups\n required: false\n kinds: [ui, spec]\n"); + expect(parseStageFrontmatter(emitted).consumes).toEqual(parsed.consumes); + }); + + test("validator: kinds on a per-unit stage passes", () => { + const r = validate({ consumes: [{ artifact: "mockups", required: false, kinds: ["ui"] }] }); + expect(r.valid).toBe(true); + }); + + test("validator: kinds on a non-per-unit stage is rejected", () => { + const r = validate({ + for_each: undefined, + consumes: [{ artifact: "mockups", required: false, kinds: ["ui"] }], + }); + expect(r.valid).toBe(false); + if (r.valid) return; + expect(r.errors.some((e) => e.includes("consumes[0].kinds requires for_each: unit-of-work"))).toBe(true); + }); + + test("validator: an unknown kind is rejected", () => { + const r = validate({ consumes: [{ artifact: "mockups", required: false, kinds: ["frontend"] }] }); + expect(r.valid).toBe(false); + if (r.valid) return; + expect(r.errors.some((e) => e.includes('consumes[0].kinds lists unknown kind "frontend"'))).toBe(true); + }); + + test("validator: an empty kinds list is rejected", () => { + const r = validate({ consumes: [{ artifact: "mockups", required: false, kinds: [] }] }); + expect(r.valid).toBe(false); + if (r.valid) return; + expect(r.errors.some((e) => e.includes("consumes[0].kinds must be a non-empty list"))).toBe(true); + }); + + test("consumeAppliesToKind: listed kind applies, other kind does not, no list / untagged keep", () => { + const gated = { kinds: ["ui"] }; + expect(consumeAppliesToKind(gated, "ui")).toBe(true); + expect(consumeAppliesToKind(gated, "service")).toBe(false); + expect(consumeAppliesToKind(gated, null)).toBe(true); + expect(consumeAppliesToKind({}, "service")).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// engine + sensor, against a graph where functional-design consumes +// `wireframes` for ui units only +// --------------------------------------------------------------------------- + +function constructionState(): string { + return `# AI-DLC State Tracking + +## Project Information +- **Project**: consumes kinds test +- **Project Type**: Greenfield +- **Scope**: feature +- **State Version**: 8 +- **Skeleton Stance**: on +## Scope Configuration +- **Stages to Execute**: all +- **Stages to Skip**: none +- **Depth**: Standard +- **Test Strategy**: Standard +- **Change Control**: strict (from scope feature) + +## Stage Progress + +### CONSTRUCTION PHASE +- [-] functional-design — EXECUTE +- [ ] nfr-requirements — EXECUTE +- [ ] nfr-design — EXECUTE +- [ ] infrastructure-design — EXECUTE +- [ ] code-generation — EXECUTE +- [ ] build-and-test — EXECUTE + +### INCEPTION PHASE +- [-] domain-design — EXECUTE + +## Current Status +- **Lifecycle Phase**: CONSTRUCTION +- **Current Stage**: functional-design +- **Status**: Running +`; +} + +function gatedGraph(): string { + const graph = JSON.parse(readFileSync(SHIPPED_GRAPH, "utf-8")) as Array<{ + slug: string; + consumes: Array>; + }>; + const fd = graph.find((s) => s.slug === "functional-design"); + if (!fd) throw new Error("functional-design missing from the shipped graph"); + fd.consumes.push({ artifact: "wireframes", required: false, kinds: ["ui"] }); + const dir = mkdtempSync(join(tmpdir(), "t338-graph-")); + tempDirs.push(dir); + const path = join(dir, "stage-graph.json"); + writeFileSync(path, JSON.stringify(graph)); + return path; +} + +function seedProject(units: Array<{ name: string; kind?: string }>): string { + const proj = createTestProject(); + tempDirs.push(proj); + seedAidlcMemory(proj); + writeFileSync(seededStateFile(proj), constructionState()); + seedBoltDag(proj, units); + const wf = join(proj, WIREFRAMES); + mkdirSync(join(wf, ".."), { recursive: true }); + writeFileSync(wf, "# Wireframes\n\n+---+\n| x |\n+---+\n"); + return proj; +} + +function envWith(graph: string): NodeJS.ProcessEnv { + const e: NodeJS.ProcessEnv = { ...process.env, AIDLC_STAGE_GRAPH: graph }; + delete e.AWS_AIDLC_DEFAULT_SCOPE; + return e; +} + +function runNext(proj: string, graph: string): Record { + const r = runOrchestrateNext(ORCH, proj, [], { env: envWith(graph) }); + if (r.directive === null) { + throw new Error(`next emitted no JSON. status=${r.status}\n${r.stdout}\n${r.stderr}`); + } + return r.directive; +} + +function consumesOf(d: Record): string[] { + return Array.isArray(d.consumes) ? (d.consumes as string[]) : []; +} + +describe("t338 consumes[].kinds - directive resolution", () => { + test("a ui unit's directive carries the kinds-gated consume", () => { + const graph = gatedGraph(); + const d = runNext(seedProject([{ name: "web", kind: "ui" }]), graph); + expect(d.stage).toBe("functional-design"); + expect(d.unit).toBe("web"); + expect(consumesOf(d)).toContain(WIREFRAMES); + }, 30000); + + test("a service unit's directive omits it", () => { + const graph = gatedGraph(); + const d = runNext(seedProject([{ name: "api", kind: "service" }]), graph); + expect(d.unit).toBe("api"); + expect(consumesOf(d)).not.toContain(WIREFRAMES); + }, 30000); + + test("an untagged unit keeps it (fail-open, like produces_kinds)", () => { + const graph = gatedGraph(); + const d = runNext(seedProject([{ name: "svc" }]), graph); + expect(d.unit).toBe("svc"); + expect(consumesOf(d)).toContain(WIREFRAMES); + }, 30000); +}); + +// The dispatcher threads `--consumes` to upstream-coverage from the stage's +// consumes filtered to files on disk. With only wireframes on disk, a ui +// unit's fire must demand a citation of it (FAILED when absent) and a service +// unit's fire must not (PASSED with the same uncited deliverable). +function fireUpstreamCoverage( + proj: string, + graph: string, + unit: string, +): { result: string; detail: string } { + const dir = join(seededRecordDir(proj), "construction", unit, "functional-design"); + mkdirSync(dir, { recursive: true }); + const spec = join(dir, "functional-spec.md"); + writeFileSync(spec, `# Functional spec for ${unit}\n\nNo upstream cited here.\n`); + const res = spawnSync( + BUN, + [SENSOR, "fire", "upstream-coverage", "--stage", "functional-design", "--output-path", spec], + { encoding: "utf-8", cwd: proj, env: { ...envWith(graph), CLAUDE_PROJECT_DIR: toPortablePath(proj) } }, + ); + const line = (res.stdout ?? "").trim().split("\n").pop() ?? ""; + let verdict: { result?: string; detail_path?: string | null } = {}; + try { + verdict = JSON.parse(line); + } catch { + throw new Error(`fire emitted no verdict. status=${res.status}\n${res.stdout}\n${res.stderr}`); + } + const detail = verdict.detail_path ? readFileSync(join(proj, verdict.detail_path), "utf-8") : ""; + return { result: verdict.result ?? "", detail }; +} + +describe("t338 consumes[].kinds - upstream-coverage threading", () => { + test("a ui unit's fire demands the wireframes citation", () => { + const graph = gatedGraph(); + const proj = seedProject([{ name: "web", kind: "ui" }]); + const { result, detail } = fireUpstreamCoverage(proj, graph, "web"); + expect(result).toBe("failed"); + expect(detail).toContain("wireframes"); + }, 30000); + + test("a service unit's fire never threads wireframes, so the same deliverable passes", () => { + const graph = gatedGraph(); + const proj = seedProject([{ name: "api", kind: "service" }]); + const { result } = fireUpstreamCoverage(proj, graph, "api"); + expect(result).toBe("passed"); + }, 30000); +}); From aab91afed7e88da37be41933887776e5f7196c77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=AA=20B=E1=BA=A3o=20Duy?= Date: Sat, 3 Oct 2026 11:39:44 +0700 Subject: [PATCH 2/2] chore: drop the explanatory comments added by this change --- core/tools/aidlc-lib.ts | 3 --- core/tools/aidlc-sensor.ts | 2 -- core/tools/aidlc-stage-schema.ts | 6 ------ tests/unit/t338-consumes-kinds.test.ts | 25 ------------------------- 4 files changed, 36 deletions(-) diff --git a/core/tools/aidlc-lib.ts b/core/tools/aidlc-lib.ts index 270f2acf23..6c7b645adb 100644 --- a/core/tools/aidlc-lib.ts +++ b/core/tools/aidlc-lib.ts @@ -36297,9 +36297,6 @@ export function filterProducesByKind( }); } -// The consumer-side twin of filterProducesByKind: a consume with a `kinds` -// list applies only to a unit of one of those kinds. No list, or an untagged -// unit (`unitKind` null), keeps the consume. export function consumeAppliesToKind( consume: { kinds?: string[] }, unitKind: string | null diff --git a/core/tools/aidlc-sensor.ts b/core/tools/aidlc-sensor.ts index 0474994174..ad75bbb303 100644 --- a/core/tools/aidlc-sensor.ts +++ b/core/tools/aidlc-sensor.ts @@ -473,8 +473,6 @@ function artifactDirsForProducer( return [join(rec, producer.phase, producer.slug)]; } -// Unit kind for a per-unit output, from its `/construction///` -// path and the unit DAG; null (keep every consume) when either is absent. function unitKindForOutput( pd: string, stage: { slug: string; for_each?: string }, diff --git a/core/tools/aidlc-stage-schema.ts b/core/tools/aidlc-stage-schema.ts index 39655f6b0b..1eda548951 100644 --- a/core/tools/aidlc-stage-schema.ts +++ b/core/tools/aidlc-stage-schema.ts @@ -64,10 +64,6 @@ export interface StageFrontmatter { artifact: string; required: boolean; conditional_on?: "brownfield" | "greenfield"; - // kinds - optional per-kind applicability of ONE consume on a per-unit - // stage: the unit kinds (UNIT_KINDS) this input applies to. Absent = every - // kind. Mirrors produces_kinds on the consumer side, so a once-per-workflow - // artifact (a UI mockup) can be an input of `ui` units only. kinds?: string[]; }>; requires_stage: string[]; @@ -583,8 +579,6 @@ export function validateStageFrontmatter( } } - // kinds - only a per-unit stage has a unit kind to filter on; anywhere - // else the list would be silently inert, so it is rejected outright. if ("kinds" in e && e.kinds !== undefined) { if (o.for_each !== "unit-of-work") { errors.push(`consumes[${i}].kinds requires for_each: unit-of-work`); diff --git a/tests/unit/t338-consumes-kinds.test.ts b/tests/unit/t338-consumes-kinds.test.ts index 11d94dddd6..2125577aba 100644 --- a/tests/unit/t338-consumes-kinds.test.ts +++ b/tests/unit/t338-consumes-kinds.test.ts @@ -1,18 +1,4 @@ // covers: function:parseStageFrontmatter, function:emitStageFrontmatter, function:validateStageFrontmatter, function:consumeAppliesToKind, subcommand:aidlc-orchestrate:next, subcommand:aidlc-sensor:fire -// -// t338 - `consumes[].kinds`: the consumer-side twin of produces_kinds. A -// per-unit stage may mark one consume as applying to some unit kinds only; the -// directive builder drops it for a unit of another kind and the upstream- -// coverage dispatcher stops threading it, so a backend unit is never asked to -// cite a UI mockup it never read. Absent list, or an untagged unit, keeps the -// consume - the same fail-open posture filterProducesByKind has. -// -// Four surfaces, one file: the frontmatter parse/emit round-trip and the schema -// validator (in-process, shipped bytes), then the engine `next` directive and -// the sensor `fire` dispatcher (cli, spawned). Both spawned cases point -// AIDLC_STAGE_GRAPH at a copy of the shipped graph with ONE consume added to -// functional-design - `wireframes` gated to [ui] - since no core stage declares -// `kinds` yet; the stage file is not edited. import { afterEach, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; @@ -62,9 +48,6 @@ afterEach(() => { } }); -// --------------------------------------------------------------------------- -// parse / emit / validate -// --------------------------------------------------------------------------- const STAGE = [ "---", @@ -165,10 +148,6 @@ describe("t338 consumes[].kinds - parse, emit, validate", () => { }); }); -// --------------------------------------------------------------------------- -// engine + sensor, against a graph where functional-design consumes -// `wireframes` for ui units only -// --------------------------------------------------------------------------- function constructionState(): string { return `# AI-DLC State Tracking @@ -275,10 +254,6 @@ describe("t338 consumes[].kinds - directive resolution", () => { }, 30000); }); -// The dispatcher threads `--consumes` to upstream-coverage from the stage's -// consumes filtered to files on disk. With only wireframes on disk, a ui -// unit's fire must demand a citation of it (FAILED when absent) and a service -// unit's fire must not (PASSED with the same uncited deliverable). function fireUpstreamCoverage( proj: string, graph: string,