From ff971c722817f91de08e6ae67eca4f6231d7f0bb Mon Sep 17 00:00:00 2001 From: egallmann Date: Sat, 14 Mar 2026 16:20:03 -0400 Subject: [PATCH 1/2] Add RECON implementation intent evidence export --- python-scripts/ast_parser.py | 65 ++++++- src/recon/implementation-intent.test.ts | 113 ++++++++++++ src/recon/implementation-intent.ts | 172 ++++++++++++++++++ .../phases/extraction-cloudformation.test.ts | 34 +++- src/recon/phases/extraction-cloudformation.ts | 35 ++++ src/recon/phases/extraction.test.ts | 27 ++- src/recon/phases/extraction.ts | 3 + src/recon/phases/index.test.ts | 47 ++++- src/recon/phases/index.ts | 12 ++ src/recon/phases/normalization.test.ts | 50 +++++ src/recon/phases/normalization.ts | 6 + src/recon/phases/population.ts | 6 + 12 files changed, 557 insertions(+), 13 deletions(-) create mode 100644 src/recon/implementation-intent.test.ts create mode 100644 src/recon/implementation-intent.ts diff --git a/python-scripts/ast_parser.py b/python-scripts/ast_parser.py index 0c67643..11ae469 100644 --- a/python-scripts/ast_parser.py +++ b/python-scripts/ast_parser.py @@ -108,6 +108,7 @@ def function_to_dict(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Dict[str, "args": collect_args(node), "returns": safe_unparse(node.returns), "decorators": decorators, + "implementation_intent": extract_implementation_intent(node.decorator_list), "docstring": ast.get_docstring(node), "async": isinstance(node, ast.AsyncFunctionDef), } @@ -115,6 +116,9 @@ def function_to_dict(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Dict[str, def class_to_dict(node: ast.ClassDef) -> Dict[str, Any]: bases = [base for base in (safe_unparse(base) for base in node.bases) if base] + decorators = [ + dec for dec in (safe_unparse(dec) for dec in node.decorator_list) if dec + ] methods = [ function_to_dict(stmt) for stmt in node.body @@ -125,6 +129,8 @@ def class_to_dict(node: ast.ClassDef) -> Dict[str, Any]: "lineno": getattr(node, "lineno", 0), "end_lineno": getattr(node, "end_lineno", getattr(node, "lineno", 0)), "bases": bases, + "decorators": decorators, + "implementation_intent": extract_implementation_intent(node.decorator_list), "methods": methods, "docstring": ast.get_docstring(node), } @@ -159,6 +165,64 @@ def decorator_call(node: ast.AST) -> Optional[ast.Call]: return node if isinstance(node, ast.Call) else None +def _decorator_name(node: ast.AST) -> Optional[str]: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return node.attr + return None + + +def _string_arguments(call: ast.Call) -> List[str]: + values: List[str] = [] + + for arg in call.args: + value = literal_string(arg) + if value is not None: + values.append(value) + continue + values.extend(strings_from_iterable(arg)) + + for kw in call.keywords: + if kw.arg not in {"adr_id", "adr_ids", "adr", "adrs", "invariant", "invariants"}: + continue + value = literal_string(kw.value) + if value is not None: + values.append(value) + continue + values.extend(strings_from_iterable(kw.value)) + + return values + + +def extract_implementation_intent(decorators: Iterable[ast.AST]) -> Optional[Dict[str, Any]]: + attributed_adrs: List[str] = [] + enforced_invariants: List[str] = [] + + for decorator in decorators: + call = decorator_call(decorator) + if not call: + continue + name = _decorator_name(call.func) + if name in {"implements_adr", "implements_adrs"}: + attributed_adrs.extend(_string_arguments(call)) + elif name in {"enforces_invariant", "enforces_invariants"}: + enforced_invariants.extend(_string_arguments(call)) + + attributed_adrs = list(dict.fromkeys(attributed_adrs)) + enforced_invariants = list(dict.fromkeys(enforced_invariants)) + + if not attributed_adrs and not enforced_invariants: + return None + + return { + "implements_adrs": attributed_adrs, + "enforced_invariants": enforced_invariants, + "confidence": "declared", + "source": "decorator", + } + + def extract_api_endpoints( fn_nodes: Iterable[ast.FunctionDef | ast.AsyncFunctionDef], ) -> List[Dict[str, Any]]: @@ -544,4 +608,3 @@ def main() -> None: if __name__ == "__main__": main() - diff --git a/src/recon/implementation-intent.test.ts b/src/recon/implementation-intent.test.ts new file mode 100644 index 0000000..c2bc530 --- /dev/null +++ b/src/recon/implementation-intent.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import fs from 'node:fs/promises'; +import { + collectImplementationAttributionEvidence, + normalizeImplementationIntent, + writeImplementationAttributionEvidence, +} from './implementation-intent.js'; +import type { NormalizedAssertion } from './phases/index.js'; + +vi.mock('node:fs/promises'); + +describe('implementation intent helpers', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + }); + + it('normalizes declared implementation intent objects', () => { + const normalized = normalizeImplementationIntent({ + implements_adrs: ['ADR-L-0004'], + enforced_invariants: ['INV-0006'], + confidence: 'declared', + source: 'decorator', + }); + + expect(normalized).toEqual({ + implements_adrs: ['ADR-L-0004'], + enforced_invariants: ['INV-0006'], + confidence: 'declared', + source: 'decorator', + }); + }); + + it('collects evidence records from slices with implementation intent', () => { + const assertions: NormalizedAssertion[] = [ + { + _slice: { + id: 'function:claims.py:process:12', + domain: 'graph', + type: 'function', + source_files: ['claims.py'], + }, + element: { + id: 'function:claims.py:process:12', + name: 'process_claim', + implementation_intent: { + implements_adrs: ['ADR-L-0004', 'ADR-PC-0006'], + enforced_invariants: ['INV-0006'], + confidence: 'declared', + source: 'decorator', + }, + }, + provenance: { + extracted_at: '2026-03-14T00:00:00Z', + extractor: 'recon-python-extractor-v1', + file: 'claims.py', + line: 12, + language: 'python', + }, + }, + ]; + + const evidence = collectImplementationAttributionEvidence(assertions); + + expect(evidence.schema_version).toBe('1.0'); + expect(evidence.type).toBe('implementation_attribution_evidence'); + expect(evidence.records).toHaveLength(1); + expect(evidence.records[0].implementation_entity_type).toBe('function'); + expect(evidence.records[0].attributed_adrs).toEqual(['ADR-L-0004', 'ADR-PC-0006']); + expect(evidence.records[0].enforced_invariants).toEqual(['INV-0006']); + expect(evidence.records[0].provenance.source_file).toBe('claims.py'); + }); + + it('writes implementation attribution evidence to state', async () => { + const assertions: NormalizedAssertion[] = [ + { + _slice: { + id: 'cfn_template:stack.yaml:stack', + domain: 'infrastructure', + type: 'template', + source_files: ['stack.yaml'], + }, + element: { + id: 'cfn_template:stack.yaml:stack', + name: 'stack', + implementation_intent: { + implements_adrs: ['ADR-L-0004'], + enforced_invariants: [], + confidence: 'declared', + source: 'metadata', + }, + }, + provenance: { + extracted_at: '2026-03-14T00:00:00Z', + extractor: 'recon-cloudformation-extractor-v1', + file: 'stack.yaml', + line: 1, + language: 'cloudformation', + }, + }, + ]; + + await writeImplementationAttributionEvidence('/tmp/.ste/state', assertions); + + expect(fs.mkdir).toHaveBeenCalled(); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('implementation-attribution-evidence.yaml'), + expect.stringContaining('implementation_attribution_evidence'), + 'utf-8', + ); + }); +}); diff --git a/src/recon/implementation-intent.ts b/src/recon/implementation-intent.ts new file mode 100644 index 0000000..1ed5051 --- /dev/null +++ b/src/recon/implementation-intent.ts @@ -0,0 +1,172 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import yaml from 'js-yaml'; +import type { NormalizedAssertion } from './phases/index.js'; + +export interface ImplementationIntent { + implements_adrs: string[]; + enforced_invariants: string[]; + confidence: 'declared'; + source: 'decorator' | 'metadata'; +} + +interface ImplementationAttributionRecord { + implementation_entity_id: string; + implementation_entity_type: + | 'function' + | 'class' + | 'module' + | 'service' + | 'workflow' + | 'infrastructure_template' + | 'configuration_file' + | 'schema_definition' + | 'pipeline' + | 'script' + | 'data_model'; + attributed_adrs: string[]; + enforced_invariants: string[]; + provenance: { + source_file: string; + extractor: string; + commit: null; + }; + metadata: Record; +} + +interface ImplementationAttributionEvidence { + schema_version: '1.0'; + type: 'implementation_attribution_evidence'; + records: ImplementationAttributionRecord[]; +} + +function normalizeStringList(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + return value.filter((item): item is string => typeof item === 'string' && item.length > 0); +} + +export function normalizeImplementationIntent( + value: unknown, +): ImplementationIntent | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + + const record = value as Record; + const implementsAdrs = normalizeStringList(record.implements_adrs); + const enforcedInvariants = normalizeStringList(record.enforced_invariants); + const confidence = record.confidence === 'declared' ? 'declared' : undefined; + const source = + record.source === 'decorator' || record.source === 'metadata' + ? record.source + : undefined; + + if (!confidence || !source) { + return undefined; + } + + if (implementsAdrs.length === 0 && enforcedInvariants.length === 0) { + return undefined; + } + + return { + implements_adrs: implementsAdrs, + enforced_invariants: enforcedInvariants, + confidence, + source, + }; +} + +function mapSliceTypeToEntityType( + assertion: NormalizedAssertion, +): ImplementationAttributionRecord['implementation_entity_type'] | undefined { + if (assertion._slice.domain === 'graph' && assertion._slice.type === 'function') { + return 'function'; + } + + if (assertion._slice.domain === 'graph' && assertion._slice.type === 'class') { + return 'class'; + } + + if (assertion._slice.domain === 'graph' && assertion._slice.type === 'module') { + return 'module'; + } + + if (assertion._slice.domain === 'data' && assertion._slice.type === 'entity') { + return 'data_model'; + } + + if (assertion._slice.domain === 'infrastructure' && assertion._slice.type === 'template') { + return 'infrastructure_template'; + } + + return undefined; +} + +export function collectImplementationAttributionEvidence( + assertions: NormalizedAssertion[], +): ImplementationAttributionEvidence { + const records: ImplementationAttributionRecord[] = []; + + for (const assertion of assertions) { + const intent = normalizeImplementationIntent(assertion.element.implementation_intent); + if (!intent) { + continue; + } + + const implementationEntityType = mapSliceTypeToEntityType(assertion); + if (!implementationEntityType) { + continue; + } + + records.push({ + implementation_entity_id: String(assertion.element.id ?? assertion._slice.id), + implementation_entity_type: implementationEntityType, + attributed_adrs: intent.implements_adrs, + enforced_invariants: intent.enforced_invariants, + provenance: { + source_file: assertion.provenance.file, + extractor: assertion.provenance.extractor, + commit: null, + }, + metadata: { + source: intent.source, + confidence: intent.confidence, + slice_id: assertion._slice.id, + }, + }); + } + + records.sort((left, right) => + left.implementation_entity_id.localeCompare(right.implementation_entity_id), + ); + + return { + schema_version: '1.0', + type: 'implementation_attribution_evidence', + records, + }; +} + +export async function writeImplementationAttributionEvidence( + stateDir: string, + assertions: NormalizedAssertion[], +): Promise { + const evidence = collectImplementationAttributionEvidence(assertions); + const attributionDir = path.join(stateDir, 'attribution'); + const targetPath = path.join(attributionDir, 'implementation-attribution-evidence.yaml'); + + await fs.mkdir(attributionDir, { recursive: true }); + await fs.writeFile( + targetPath, + yaml.dump(evidence, { + noRefs: true, + lineWidth: -1, + sortKeys: false, + }), + 'utf-8', + ); +} diff --git a/src/recon/phases/extraction-cloudformation.test.ts b/src/recon/phases/extraction-cloudformation.test.ts index 2cfe3dc..c703e56 100644 --- a/src/recon/phases/extraction-cloudformation.test.ts +++ b/src/recon/phases/extraction-cloudformation.test.ts @@ -45,6 +45,39 @@ Resources: expect(resources[0].metadata.type).toBe('AWS::S3::Bucket'); }); + it('should extract template-level implementation intent from metadata', async () => { + const cfnFile: DiscoveredFile = { + path: '/test/template-intent.yaml', + relativePath: 'template-intent.yaml', + language: 'cloudformation', + changeType: 'added' + }; + + const template = ` +AWSTemplateFormatVersion: '2010-09-09' +Metadata: + implements-adrs: + - ADR-L-0004 + - ADR-PS-0004 +Resources: + MyBucket: + Type: AWS::S3::Bucket +`; + + vi.mocked(fs.readFile).mockResolvedValue(template); + + const assertions = await extractFromCloudFormation(cfnFile); + const cfnTemplate = assertions.find(a => a.elementType === 'cfn_template'); + + expect(cfnTemplate).toBeDefined(); + expect(cfnTemplate?.metadata.implementationIntent).toEqual({ + implements_adrs: ['ADR-L-0004', 'ADR-PS-0004'], + enforced_invariants: [], + confidence: 'declared', + source: 'metadata', + }); + }); + it('should parse JSON CloudFormation template', async () => { const cfnFile: DiscoveredFile = { path: '/test/template.json', @@ -606,4 +639,3 @@ Resources: }); }); }); - diff --git a/src/recon/phases/extraction-cloudformation.ts b/src/recon/phases/extraction-cloudformation.ts index fb21668..4b5fe0a 100644 --- a/src/recon/phases/extraction-cloudformation.ts +++ b/src/recon/phases/extraction-cloudformation.ts @@ -121,6 +121,40 @@ interface CfnOutput { Condition?: string; } +function stringListFromMetadata(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + return value.filter((item): item is string => typeof item === 'string' && item.length > 0); +} + +function extractTemplateImplementationIntent( + metadata: Record | undefined, +): Record | undefined { + if (!metadata) { + return undefined; + } + + const implementsAdrs = stringListFromMetadata( + metadata['implements-adrs'] ?? metadata.implements_adrs, + ); + const enforcedInvariants = stringListFromMetadata( + metadata['enforces-invariants'] ?? metadata.enforces_invariants, + ); + + if (implementsAdrs.length === 0 && enforcedInvariants.length === 0) { + return undefined; + } + + return { + implements_adrs: implementsAdrs, + enforced_invariants: enforcedInvariants, + confidence: 'declared', + source: 'metadata', + }; +} + /** * Extract assertions from a CloudFormation template file. * @@ -184,6 +218,7 @@ export async function extractFromCloudFormation(file: DiscoveredFile): Promise { args: ['name'], returns: 'str', decorators: [], + implementation_intent: { + implements_adrs: ['ADR-L-0004'], + enforced_invariants: [], + confidence: 'declared', + source: 'decorator', + }, docstring: 'Greet a user', async: false }, @@ -487,6 +493,12 @@ describe('extractAssertions', () => { expect(greetFunc).toBeDefined(); expect(greetFunc?.metadata.args).toEqual(['name']); expect(greetFunc?.metadata.docstring).toBe('Greet a user'); + expect(greetFunc?.metadata.implementationIntent).toEqual({ + implements_adrs: ['ADR-L-0004'], + enforced_invariants: [], + confidence: 'declared', + source: 'decorator', + }); expect(greetFunc?.metadata.async).toBe(false); expect(greetFunc?.signature).toContain('def greet'); @@ -509,6 +521,13 @@ describe('extractAssertions', () => { name: 'User', lineno: 1, bases: ['BaseModel'], + decorators: ['implements_adr("ADR-L-0004")'], + implementation_intent: { + implements_adrs: ['ADR-L-0004'], + enforced_invariants: ['INV-0006'], + confidence: 'declared', + source: 'decorator', + }, methods: [ { name: '__init__' }, { name: 'save' }, @@ -534,6 +553,13 @@ describe('extractAssertions', () => { const userClass = classAssertions[0]; expect(userClass.metadata.name).toBe('User'); expect(userClass.metadata.bases).toEqual(['BaseModel']); + expect(userClass.metadata.decorators).toEqual(['implements_adr("ADR-L-0004")']); + expect(userClass.metadata.implementationIntent).toEqual({ + implements_adrs: ['ADR-L-0004'], + enforced_invariants: ['INV-0006'], + confidence: 'declared', + source: 'decorator', + }); expect(userClass.metadata.methods).toEqual(['__init__', 'save', 'delete']); expect(userClass.metadata.docstring).toBe('User model class'); }); @@ -749,4 +775,3 @@ describe('extractAssertions', () => { }); }); }); - diff --git a/src/recon/phases/extraction.ts b/src/recon/phases/extraction.ts index 18faf53..ba7673e 100644 --- a/src/recon/phases/extraction.ts +++ b/src/recon/phases/extraction.ts @@ -230,6 +230,7 @@ async function extractFromPython(file: DiscoveredFile): Promise args: fn.args ?? [], returns: fn.returns, decorators: fn.decorators ?? [], + implementationIntent: fn.implementation_intent, docstring: fn.docstring, async: fn.async ?? false, }, @@ -255,6 +256,8 @@ async function extractFromPython(file: DiscoveredFile): Promise metadata: { name: cls.name, bases: cls.bases ?? [], + decorators: cls.decorators ?? [], + implementationIntent: cls.implementation_intent, methods: (cls.methods ?? []).map((m: any) => m.name), docstring: cls.docstring, }, diff --git a/src/recon/phases/index.test.ts b/src/recon/phases/index.test.ts index 30a83f3..1552ca4 100644 --- a/src/recon/phases/index.test.ts +++ b/src/recon/phases/index.test.ts @@ -15,6 +15,7 @@ import * as inference from './inference.js'; import * as population from './population.js'; import * as divergence from './divergence.js'; import * as selfValidation from './self-validation.js'; +import * as implementationIntent from '../implementation-intent.js'; import { ProjectDiscovery } from '../../discovery/index.js'; vi.mock('./discovery.js'); @@ -24,6 +25,7 @@ vi.mock('./inference.js'); vi.mock('./population.js'); vi.mock('./divergence.js'); vi.mock('./self-validation.js'); +vi.mock('../implementation-intent.js'); const mockBuildFullManifest = vi.hoisted(() => vi.fn().mockResolvedValue({ version: 1, generatedAt: '2024-01-01T00:00:00Z', files: {} }) ); @@ -141,13 +143,36 @@ describe('runReconPhases', () => { ]); vi.mocked(population.populateAiDoc).mockResolvedValue({ - slicesWritten: 1, - createCount: 1, - updateCount: 0, - deleteCount: 0, - unchangedCount: 0, + created: 1, + updated: 0, + deleted: 0, + unchanged: 0, + priorState: new Map(), + currentState: new Map([ + [ + 'function:test.ts:test:1', + { + _slice: { + id: 'function:test.ts:test:1', + domain: 'graph', + type: 'function', + source_files: ['test.ts'], + }, + element: { id: 'function:test.ts:test:1', name: 'test' }, + provenance: { + extracted_at: '2024-01-01T00:00:00Z', + extractor: 'typescript-v1', + file: 'test.ts', + line: 1, + language: 'typescript', + }, + }, + ], + ]), }); + vi.mocked(implementationIntent.writeImplementationAttributionEvidence).mockResolvedValue(undefined); + vi.mocked(divergence.detectDivergence).mockResolvedValue({ orphanedSlices: [], semanticEnrichments: [], @@ -188,6 +213,7 @@ describe('runReconPhases', () => { expect(normalization.normalizeAssertions).toHaveBeenCalled(); expect(inference.inferRelationships).toHaveBeenCalled(); expect(population.populateAiDoc).toHaveBeenCalled(); + expect(implementationIntent.writeImplementationAttributionEvidence).toHaveBeenCalled(); expect(divergence.detectDivergence).toHaveBeenCalled(); expect(selfValidation.runSelfValidation).toHaveBeenCalled(); }); @@ -349,11 +375,12 @@ describe('runReconPhases', () => { describe('Result construction', () => { it('should return correct statistics', async () => { vi.mocked(population.populateAiDoc).mockResolvedValue({ - slicesWritten: 10, - createCount: 5, - updateCount: 3, - deleteCount: 1, - unchangedCount: 1, + created: 5, + updated: 3, + deleted: 1, + unchanged: 1, + priorState: new Map(), + currentState: new Map(), }); const options: ReconOptions = { diff --git a/src/recon/phases/index.ts b/src/recon/phases/index.ts index 88849ed..2bfe5c5 100644 --- a/src/recon/phases/index.ts +++ b/src/recon/phases/index.ts @@ -21,6 +21,7 @@ import { normalizeAssertions } from './normalization.js'; import { populateAiDoc } from './population.js'; import { detectDivergence } from './divergence.js'; import { runSelfValidation } from './self-validation.js'; +import { writeImplementationAttributionEvidence } from '../implementation-intent.js'; import { updateCoordinator } from '../../watch/update-coordinator.js'; import { buildFullManifest, writeReconManifest, type ManifestLanguage } from '../../watch/change-detector.js'; import { log } from '../../utils/logger.js'; @@ -254,6 +255,17 @@ export async function runReconPhases(options: ReconOptions): Promise { returns: 'dict', async: true, decorators: ['@cache'], + implementationIntent: { + implements_adrs: ['ADR-L-0004'], + enforced_invariants: ['INV-0006'], + confidence: 'declared', + source: 'decorator', + }, docstring: 'Fetch data from URL', }, }, @@ -75,6 +81,12 @@ describe('normalizeAssertions', () => { expect(funcSlice?.element.parameters).toEqual(['url']); expect(funcSlice?.element.docstring).toBe('Fetch data from URL'); expect(funcSlice?.element.decorators).toEqual(['@cache']); + expect(funcSlice?.element.implementation_intent).toEqual({ + implements_adrs: ['ADR-L-0004'], + enforced_invariants: ['INV-0006'], + confidence: 'declared', + source: 'decorator', + }); expect(funcSlice?.provenance.language).toBe('python'); }); @@ -308,6 +320,44 @@ describe('normalizeAssertions', () => { }); }); + describe('CloudFormation normalization', () => { + it('should carry template implementation intent into normalized slices', async () => { + const rawAssertions: RawAssertion[] = [ + { + elementId: 'cfn_template:infra/template.yaml:template', + elementType: 'cfn_template', + file: 'infra/template.yaml', + line: 1, + language: 'cloudformation', + metadata: { + name: 'template', + description: 'stack', + resourceCount: 1, + parameterCount: 0, + outputCount: 0, + implementationIntent: { + implements_adrs: ['ADR-L-0004', 'ADR-PS-0004'], + enforced_invariants: [], + confidence: 'declared', + source: 'metadata', + }, + }, + }, + ]; + + const normalized = await normalizeAssertions(rawAssertions, projectRoot); + const templateSlice = normalized.find(n => n._slice.type === 'template'); + + expect(templateSlice).toBeDefined(); + expect(templateSlice?.element.implementation_intent).toEqual({ + implements_adrs: ['ADR-L-0004', 'ADR-PS-0004'], + enforced_invariants: [], + confidence: 'declared', + source: 'metadata', + }); + }); + }); + describe('Data model normalization', () => { it('should normalize Python data models', async () => { const rawAssertions: RawAssertion[] = [ diff --git a/src/recon/phases/normalization.ts b/src/recon/phases/normalization.ts index b83373c..2825515 100644 --- a/src/recon/phases/normalization.ts +++ b/src/recon/phases/normalization.ts @@ -15,6 +15,7 @@ import path from 'node:path'; import type { RawAssertion, NormalizedAssertion } from './index.js'; import type { SupportedLanguage } from '../../config/index.js'; import { generateModuleId } from '../../utils/paths.js'; +import { normalizeImplementationIntent } from '../implementation-intent.js'; /** * Get extractor name for a language @@ -183,6 +184,7 @@ function normalizeElement( timestamp: string ): NormalizedAssertion | null { const extractor = getExtractorName(assertion.language); + const implementationIntent = normalizeImplementationIntent(assertion.metadata.implementationIntent); if (assertion.elementType === 'function') { return { @@ -209,6 +211,7 @@ function normalizeElement( deprecated: assertion.metadata.deprecated, tags: assertion.metadata.tags, decorators: assertion.metadata.decorators, + implementation_intent: implementationIntent, // Method-specific properties (for class methods) isMethod: assertion.metadata.isMethod as boolean | undefined, className: assertion.metadata.className as string | undefined, @@ -246,6 +249,8 @@ function normalizeElement( examples: assertion.metadata.examples, deprecated: assertion.metadata.deprecated, tags: assertion.metadata.tags, + decorators: assertion.metadata.decorators, + implementation_intent: implementationIntent, }, provenance: { extracted_at: timestamp, @@ -397,6 +402,7 @@ function normalizeElement( resourceCount: assertion.metadata.resourceCount, parameterCount: assertion.metadata.parameterCount, outputCount: assertion.metadata.outputCount, + implementation_intent: implementationIntent, }, provenance: { extracted_at: timestamp, diff --git a/src/recon/phases/population.ts b/src/recon/phases/population.ts index ba91dc2..c83eb10 100644 --- a/src/recon/phases/population.ts +++ b/src/recon/phases/population.ts @@ -31,6 +31,7 @@ export interface PopulationResult { deleted: number; unchanged: number; priorState: Map; + currentState: Map; } export interface PopulationOptions { @@ -73,6 +74,7 @@ export async function populateAiDoc( let updated = 0; let deleted = 0; let unchanged = 0; + const currentState = new Map(priorState); // If full reconciliation, we'll delete everything not in the new assertions if (options.fullReconciliation) { @@ -86,6 +88,7 @@ export async function populateAiDoc( try { await fs.unlink(targetPath); deleted++; + currentState.delete(priorId); log(`[RECON Population] Deleted orphan: ${priorId}`); } catch (_error) { // File might not exist, ignore @@ -132,6 +135,7 @@ export async function populateAiDoc( try { await fs.unlink(targetPath); deleted++; + currentState.delete(priorId); log(`[RECON Population] Deleted orphan: ${priorId}`); } catch (_error) { // File might not exist (already deleted or misnamed), log but continue @@ -201,6 +205,7 @@ export async function populateAiDoc( const writeFileStart = performance.now(); await fs.writeFile(targetPath, yamlContent, 'utf-8'); writeTime += performance.now() - writeFileStart; + currentState.set(assertion._slice.id, assertion); // Track write for watchdog (E-ADR-007) const trackerStart = performance.now(); @@ -234,6 +239,7 @@ export async function populateAiDoc( deleted, unchanged, priorState, + currentState, }; } From fe895d6ed6fffa1700dfc38e1037507a24d22476 Mon Sep 17 00:00:00 2001 From: egallmann Date: Sat, 14 Mar 2026 16:29:03 -0400 Subject: [PATCH 2/2] Add TypeScript decorator intent extraction --- src/recon/phases/extraction.test.ts | 102 +++++++++++++++++++++ src/recon/phases/extraction.ts | 118 +++++++++++++++++++++++-- src/recon/phases/normalization.test.ts | 28 ++++++ 3 files changed, 240 insertions(+), 8 deletions(-) diff --git a/src/recon/phases/extraction.test.ts b/src/recon/phases/extraction.test.ts index f161302..ee37bc7 100644 --- a/src/recon/phases/extraction.test.ts +++ b/src/recon/phases/extraction.test.ts @@ -90,6 +90,108 @@ describe('extractAssertions', () => { expect(userClass.metadata.methods).toContain('getUsers'); }); + it('should extract implementation intent from TypeScript function decorators', async () => { + const tsFile: DiscoveredFile = { + path: '/test/intent.ts', + relativePath: 'intent.ts', + language: 'typescript' + }; + + const tsContent = ` + @implements_adr('ADR-L-0004') + @enforces_invariant(['INV-0006']) + export function processClaim(id: string): string { + return id; + } + `; + + vi.mocked(fs.readFile).mockResolvedValue(tsContent); + + const assertions = await extractAssertions([tsFile]); + const func = assertions.find(a => a.elementType === 'function' && a.metadata.name === 'processClaim'); + + expect(func).toBeDefined(); + expect(func?.metadata.decorators).toEqual([ + "@implements_adr('ADR-L-0004')", + "@enforces_invariant(['INV-0006'])", + ]); + expect(func?.metadata.implementationIntent).toEqual({ + implements_adrs: ['ADR-L-0004'], + enforced_invariants: ['INV-0006'], + confidence: 'declared', + source: 'decorator', + }); + }); + + it('should extract implementation intent from TypeScript class and method decorators', async () => { + const tsFile: DiscoveredFile = { + path: '/test/decorated-class.ts', + relativePath: 'decorated-class.ts', + language: 'typescript' + }; + + const tsContent = ` + @implements_adrs(['ADR-L-0004', 'ADR-PC-0006']) + export class ClaimService { + @enforces_invariant('INV-0006') + validateClaim(claimId: string) { + return claimId; + } + } + `; + + vi.mocked(fs.readFile).mockResolvedValue(tsContent); + + const assertions = await extractAssertions([tsFile]); + const claimClass = assertions.find(a => a.elementType === 'class' && a.metadata.name === 'ClaimService'); + const validateMethod = assertions.find( + a => a.elementType === 'function' && a.metadata.name === 'validateClaim' + ); + + expect(claimClass?.metadata.decorators).toEqual([ + "@implements_adrs(['ADR-L-0004', 'ADR-PC-0006'])", + ]); + expect(claimClass?.metadata.implementationIntent).toEqual({ + implements_adrs: ['ADR-L-0004', 'ADR-PC-0006'], + enforced_invariants: [], + confidence: 'declared', + source: 'decorator', + }); + + expect(validateMethod?.metadata.decorators).toEqual(["@enforces_invariant('INV-0006')"]); + expect(validateMethod?.metadata.implementationIntent).toEqual({ + implements_adrs: [], + enforced_invariants: ['INV-0006'], + confidence: 'declared', + source: 'decorator', + }); + }); + + it('should ignore non-literal TypeScript decorator arguments for implementation intent', async () => { + const tsFile: DiscoveredFile = { + path: '/test/nonliteral.ts', + relativePath: 'nonliteral.ts', + language: 'typescript' + }; + + const tsContent = ` + const ADR_ID = 'ADR-L-0004'; + @implements_adr(ADR_ID) + export function processClaim(id: string): string { + return id; + } + `; + + vi.mocked(fs.readFile).mockResolvedValue(tsContent); + + const assertions = await extractAssertions([tsFile]); + const func = assertions.find(a => a.elementType === 'function' && a.metadata.name === 'processClaim'); + + expect(func).toBeDefined(); + expect(func?.metadata.decorators).toEqual(["@implements_adr(ADR_ID)"]); + expect(func?.metadata.implementationIntent).toBeUndefined(); + }); + it('should extract imports from TypeScript files', async () => { const tsFile: DiscoveredFile = { path: '/test/app.ts', diff --git a/src/recon/phases/extraction.ts b/src/recon/phases/extraction.ts index ba7673e..a4faa5c 100644 --- a/src/recon/phases/extraction.ts +++ b/src/recon/phases/extraction.ts @@ -428,25 +428,25 @@ function extractJsDoc(node: ts.Node, sourceFile: ts.SourceFile): JsDocInfo { if (!jsDocNodes || jsDocNodes.length === 0) { return {}; } - + const jsDoc = jsDocNodes[0]; // Get first JSDoc block const info: JsDocInfo = {}; - + // Extract full comment text if (jsDoc.comment) { - const fullText = typeof jsDoc.comment === 'string' - ? jsDoc.comment + const fullText = typeof jsDoc.comment === 'string' + ? jsDoc.comment : jsDoc.comment.map((part: any) => part.text).join(''); - + info.docstring = fullText.trim(); - + // Extract description (first paragraph/line) const firstParagraph = fullText.split('\n\n')[0].trim(); if (firstParagraph) { info.description = firstParagraph; } } - + // Extract JSDoc tags if (jsDoc.tags && Array.isArray(jsDoc.tags)) { const params: Array<{ name: string; type?: string; description?: string }> = []; @@ -523,10 +523,100 @@ function extractJsDoc(node: ts.Node, sourceFile: ts.SourceFile): JsDocInfo { info.tags = customTags; } } - + return info; } +function getDecoratorName(expression: ts.LeftHandSideExpression): string | null { + if (ts.isIdentifier(expression)) { + return expression.text; + } + + if (ts.isPropertyAccessExpression(expression)) { + return expression.name.text; + } + + return null; +} + +function extractStringLiteralValues(node: ts.Expression): string[] { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return [node.text]; + } + + if (ts.isArrayLiteralExpression(node)) { + const values: string[] = []; + for (const element of node.elements) { + if (!ts.isStringLiteral(element) && !ts.isNoSubstitutionTemplateLiteral(element)) { + return []; + } + values.push(element.text); + } + return values; + } + + return []; +} + +function extractRawDecorators(node: ts.Node, sourceFile: ts.SourceFile): string[] { + const decorators = ts.getDecorators(node as ts.HasDecorators); + if (!decorators) { + return []; + } + + return decorators.map(decorator => decorator.getText(sourceFile)); +} + +function extractTypeScriptImplementationIntent( + node: ts.Node, +): Record | undefined { + const decorators = ts.getDecorators(node as ts.HasDecorators); + if (!decorators) { + return undefined; + } + + const implementsAdrs: string[] = []; + const enforcedInvariants: string[] = []; + + for (const decorator of decorators) { + if (!ts.isCallExpression(decorator.expression)) { + continue; + } + + const decoratorName = getDecoratorName(decorator.expression.expression); + if (!decoratorName) { + continue; + } + + const args = decorator.expression.arguments.flatMap(extractStringLiteralValues); + if (args.length === 0) { + continue; + } + + if (decoratorName === 'implements_adr' || decoratorName === 'implements_adrs') { + implementsAdrs.push(...args); + } + + if (decoratorName === 'enforces_invariant' || decoratorName === 'enforces_invariants') { + enforcedInvariants.push(...args); + } + } + + const uniqueAdrs = [...new Set(implementsAdrs)]; + const uniqueInvariants = [...new Set(enforcedInvariants)]; + + if (uniqueAdrs.length === 0 && uniqueInvariants.length === 0) { + return undefined; + } + + return { + implements_adrs: uniqueAdrs, + enforced_invariants: uniqueInvariants, + confidence: 'declared', + source: 'decorator', + }; +} + /** * Extract assertions from a TypeScript file using the TypeScript compiler API */ @@ -565,6 +655,8 @@ async function extractFromTypeScript(file: DiscoveredFile): Promise m.kind === ts.SyntaxKind.AsyncKeyword)), parameters: node.parameters.map(p => p.name.getText(sourceFile)), + decorators, + implementationIntent, ...jsDocInfo, }, }); @@ -602,6 +696,8 @@ async function extractFromTypeScript(file: DiscoveredFile): Promise m.kind === ts.SyntaxKind.AsyncKeyword)), isStatic: !!(member.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword)), parameters: member.parameters.map(p => p.name.getText(sourceFile)), + decorators: methodDecorators, + implementationIntent: methodImplementationIntent, ...methodJsDoc, }, }); diff --git a/src/recon/phases/normalization.test.ts b/src/recon/phases/normalization.test.ts index 2e9ae56..7dbeed1 100644 --- a/src/recon/phases/normalization.test.ts +++ b/src/recon/phases/normalization.test.ts @@ -24,6 +24,13 @@ describe('normalizeAssertions', () => { isExported: true, isAsync: false, parameters: ['name'], + decorators: ["@implements_adr('ADR-L-0004')"], + implementationIntent: { + implements_adrs: ['ADR-L-0004'], + enforced_invariants: [], + confidence: 'declared', + source: 'decorator', + }, }, }, ]; @@ -41,6 +48,13 @@ describe('normalizeAssertions', () => { expect(funcSlice?.element.is_exported).toBe(true); expect(funcSlice?.element.is_async).toBe(false); expect(funcSlice?.element.parameters).toEqual(['name']); + expect(funcSlice?.element.decorators).toEqual(["@implements_adr('ADR-L-0004')"]); + expect(funcSlice?.element.implementation_intent).toEqual({ + implements_adrs: ['ADR-L-0004'], + enforced_invariants: [], + confidence: 'declared', + source: 'decorator', + }); expect(funcSlice?.provenance.file).toBe('app.ts'); expect(funcSlice?.provenance.line).toBe(5); expect(funcSlice?.provenance.language).toBe('typescript'); @@ -128,6 +142,13 @@ describe('normalizeAssertions', () => { isExported: true, methods: ['constructor', 'getUser', 'saveUser'], properties: ['users', 'cache'], + decorators: ["@implements_adrs(['ADR-L-0004', 'ADR-PC-0006'])"], + implementationIntent: { + implements_adrs: ['ADR-L-0004', 'ADR-PC-0006'], + enforced_invariants: [], + confidence: 'declared', + source: 'decorator', + }, }, }, ]; @@ -141,6 +162,13 @@ describe('normalizeAssertions', () => { expect(classSlice?.element.is_exported).toBe(true); expect(classSlice?.element.methods).toEqual(['constructor', 'getUser', 'saveUser']); expect(classSlice?.element.properties).toEqual(['users', 'cache']); + expect(classSlice?.element.decorators).toEqual(["@implements_adrs(['ADR-L-0004', 'ADR-PC-0006'])"]); + expect(classSlice?.element.implementation_intent).toEqual({ + implements_adrs: ['ADR-L-0004', 'ADR-PC-0006'], + enforced_invariants: [], + confidence: 'declared', + source: 'decorator', + }); }); it('should normalize Python classes with inheritance', async () => {