Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ Performs a structural comparison between the contract definition and observed su
- `warning`: Non-critical issue that may indicate drift
- `info`: Informational difference

Includes traceability fields.

**Drift Risks Detected:**
- `diff-noise`: Formatting-only or reorder-only changes filtered
- `semantic-ambiguity`: Diff entries lack rule/clause references
Expand Down Expand Up @@ -212,6 +214,9 @@ Performs a structural comparison between the contract definition and observed su
"contractValue": "unknown",
"observedValue": "unknown",
"rule": "string",
"stableId": "string",
"contractRef": { "path": "string", "surfaceId": "string", "sectionId": "string", "constraintId": "string" },
"ruleRef": { "id": "string", "version": "string" },
"rename": {
"fromPath": "string",
"toPath": "string",
Expand Down Expand Up @@ -281,6 +286,8 @@ Applies enforcement policies to interface contracts through three modes:
- Respects safety levels and rule patterns
- Can run in `--dry-run` mode to preview changes

Includes traceability fields.

3. **pr**:
- Generates patch files for review
- Supports unified diff or JSON patch formats
Expand Down Expand Up @@ -316,7 +323,10 @@ Applies enforcement policies to interface contracts through three modes:
"path": "string",
"oldValue": "unknown",
"newValue": "unknown",
"confidence": "number"
"confidence": "number",
"stableId": "string",
"contractRef": { "path": "string", "surfaceId": "string", "sectionId": "string", "constraintId": "string" },
"ruleRef": { "id": "string", "version": "string" }
}
],
"skipped": [
Expand All @@ -325,21 +335,36 @@ Applies enforcement policies to interface contracts through three modes:
"path": "string",
"oldValue": "unknown",
"newValue": "unknown",
"confidence": "number"
"confidence": "number",
"stableId": "string",
"contractRef": { "path": "string", "surfaceId": "string", "sectionId": "string", "constraintId": "string" },
"ruleRef": { "id": "string", "version": "string" }
}
],
"errors": [
{
"ruleId": "string",
"path": "string",
"message": "string"
"message": "string",
"stableId": "string",
"ruleRef": { "id": "string", "version": "string" }
}
]
}
```

---

## Traceability fields (Phase 2)

Diff and enforce JSON output include optional traceability fields for correlation and debugging. These fields are additive and optional; existing consumers should keep working.

- **stableId**: Deterministic correlation id (64-bit). Use for deduplication and change tracking within a repo or CI workflow. NOT a globally unique identifier. For cross-repo or large-fleet indexing, consider extending to 128 bits in a future phase.
- **contractRef**: Reference to contract structure when deterministically derivable. Fields: `path` (JSON pointer style), `surfaceId`, `sectionId`, `constraintId`. `contractRef.path` is omitted when the mapping is ambiguous or the node does not exist in the contract (e.g. surface-missing).
- **ruleRef**: `id` (and optional `version`) of the rule that produced the entry.

---

## Configuration

### Config File (`interfacectl.config.json`)
Expand Down
68 changes: 68 additions & 0 deletions docs/plans/phase-2-pr-description.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# PR Description: Phase 2 Traceability and Output Stability

Use this content when opening the PR. Reference: docs/plans/phase-2-traceability-output-stability.md.

---

## Title

feat: Phase 2 traceability and output stability for diff and enforce

---

## Strategy check

- [x] I read "docs/strategy.md"
- [x] This PR strengthens the decision filter sentence (by making diff/enforce outputs machine-correlatable)
- [x] Enforcement timing is explicit: generation time (unchanged)
- [x] Violation handling is defined: unchanged (blocked via non-zero exit codes)
- [x] CLI behavior is tied to contract semantics, not free-form heuristics

## What changed

- **Diff JSON:** Every entry now includes `stableId`, `contractRef` (path, surfaceId, sectionId, constraintId when derivable), and `ruleRef` (id). Added shared `computeStableId` helper with normalized inputs for cross-OS determinism.
- **Enforce JSON:** Applied and skipped fix entries now include `stableId`, `contractRef`, and `ruleRef` when deterministically derivable.
- **Validator:** New optional types `ContractRef`, `RuleRef`; extended `DiffEntry`, `FixEntry`, `FixError` with traceability fields. Output schemas updated to allow new optional properties.
- **Tests:** New `traceability.test.mjs` verifies output shape, stableId presence, contractRef/ruleRef, and stableId determinism across runs. New `traceability` fixture for diff/enforce integration.
- **API.md:** Documented new fields for diff entries and enforce applied/skipped/errors.

## Why it matters

Downstream tools can correlate diff entries and enforce findings across runs. stableId enables deduplication and change tracking. contractRef and ruleRef support debugging and mapping back to contract structure.

## Contract and enforcement notes

1. No contract schema changes. Output schemas (diff, fix-summary) extended with optional properties only.
2. Enforcement point: unchanged (generation time).
3. Expected behavior on violation: unchanged (non-zero exit codes).

## Phase 2 Acceptance Criteria (from docs/plans/phase-2-traceability-output-stability.md)

1. ✅ `diff --format json` emits `stableId` for every entry.
2. ✅ `diff` entries include `contractRef.path` for all cases where a contract node is unambiguous.
3. ✅ `enforce --format json` emits `stableId` for applied and skipped actions.
4. ✅ `enforce` output includes `contractRef` and `ruleRef` when deterministically derivable.
5. ✅ No changes to validate compliance semantics.
6. ✅ No schema changes (contract schema unchanged; output schemas extended with optional fields only).
7. ✅ A fixture test verifies stable output shape and stableId determinism for diff and enforce.

## Tests

- `test/traceability.test.mjs`: 6 tests for diff/enforce output shape, stableId, contractRef, ruleRef, determinism.
- `test/fixtures/traceability/`: New fixture with contract that produces diffs; policy that produces applied fixes.
- `test/fixtures/examples/diff.json`: Updated with traceability fields.
- `ensureRelativePaths` fix: observed.root no longer empty when workspace equals observed root (schema minLength).

## Phase 2 Pre-Merge Verification (completed)

1. **contractRef overclaim fix:** deriveContractRef omits ref.path when entry.rule === "contract.surface-missing". Keeps ref.surfaceId. Added comment acknowledging narrow guard.
2. **stableId semantics:** JSDoc in stable-id.ts and consolidated API.md section state correlation-id scope, 64-bit, not globally unique.
3. **API.md consolidation:** Single "Traceability fields (Phase 2)" section; diff and enforce reference with "Includes traceability fields." Backwards compatibility note added.
4. **Regression test:** Unit test asserts surface-missing entry has contractRef.surfaceId but omits contractRef.path.

## Phase 2 Review Checklist

- [x] PR stays in scope (diff/enforce output only; no compile, explain, gate, schema changes to contract).
- [x] New output fields documented in API.md.
- [x] Tests prove determinism and shape.
- [x] Output remains readable and not bloated.
75 changes: 75 additions & 0 deletions docs/plans/phase-2-traceability-output-stability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Phase 2 Traceability and Output Stability

## Purpose
Make diff and enforce outputs explainable and machine-correlatable over time. Reduce ambiguity without changing validation semantics or contract schema.

## Scope
In scope:
1. `diff` JSON output enrichment for traceability.
2. `enforce` JSON output enrichment for traceability.
3. Shared mapping rules so diff entries and enforce findings reference contract intent consistently.
4. Fixture-based tests that lock output shape and key fields.
5. API.md updates only if output fields change.

Out of scope:
1. New CLI commands.
2. `compile` or runtime bundle output.
3. `explain` command or code-to-text mapping.
4. Changes to validate compliance rules.
5. Contract schema changes.
6. Exit code changes.

## Desired properties
1. Deterministic output. Same inputs yield stable identifiers and stable references.
2. Traceability. Every diff entry and enforce finding carries references back to contract structure where deterministically derivable.
3. Minimalism. Add only fields that are used by downstream tools or needed for human debugging.

## Output additions

### Shared identifiers
Add these fields where applicable.

1. `contractRef`
- `path`: JSON pointer style path into the contract, when deterministic.
- `surfaceId`: surface identifier when available.
- `sectionId`: section identifier when available.
- `constraintId`: constraint category when applicable (example: motion).

2. `ruleRef`
- `id`: stable string identifier for the rule or evaluator that produced the entry.
- `version`: optional, only if a rule catalog exists.

3. `stableId`
- Deterministic identifier for correlation across runs.
- Computed from: command name, surfaceId, type, path, contractRef.path, ruleRef.id, plus a normalized representation of values when needed.

### `diff` JSON
For each entry:
1. Populate `contractRef.path` when the entry maps directly to a contract node.
2. Populate `ruleRef.id` when the diff logic can name the rule that produced the entry.
3. Populate `stableId` for every entry.

### `enforce` JSON
For each applied or skipped action and for any findings/errors:
1. Populate `contractRef` and `ruleRef` when deterministically known.
2. Populate `stableId` for each record so automated workflows can dedupe and track changes.

## Acceptance criteria
1. `diff --format json` emits `stableId` for every entry.
2. `diff` entries include `contractRef.path` for all cases where a contract node is unambiguous.
3. `enforce --format json` emits `stableId` for applied and skipped actions.
4. `enforce` output includes `contractRef` and `ruleRef` when deterministically derivable.
5. No changes to validate compliance semantics.
6. No schema changes.
7. A fixture test verifies stable output shape and stableId determinism for diff and enforce.

## Risks and guardrails
1. Do not invent semantic mappings. Only emit references when deterministic.
2. Do not add optional fields that will become de facto required. Keep additions minimal and documented.
3. stableId computation must be stable across OS and Node versions. Normalize inputs.

## Review checklist
1. Does the PR stay in scope.
2. Are new output fields documented in API.md.
3. Do tests prove determinism and shape.
4. Does output remain readable and not bloated.
2 changes: 1 addition & 1 deletion packages/interfacectl-cli/dist/commands/diff.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 19 additions & 13 deletions packages/interfacectl-cli/dist/commands/diff.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { loadPolicy } from "../utils/policy.js";
import { getExitCodeVersion } from "../utils/exit-codes.js";
import { getMaxSeverity } from "../utils/violation-classifier.js";
import { applyPolicySeverityOverrides } from "../utils/apply-policy-severity.js";
import { enrichDiffEntry } from "../utils/traceability.js";
async function loadConfigFile(configPath) {
try {
const raw = await readFile(configPath, "utf-8");
Expand Down Expand Up @@ -116,9 +117,12 @@ function ensureRelativePaths(output, workspaceRoot) {
? path.relative(workspaceRoot, contractPath)
: contractPath;
const observedRoot = output.observed.root;
const relativeObservedRoot = path.isAbsolute(observedRoot)
let relativeObservedRoot = path.isAbsolute(observedRoot)
? path.relative(workspaceRoot, observedRoot)
: observedRoot;
if (!relativeObservedRoot) {
relativeObservedRoot = ".";
}
return {
...output,
contract: {
Expand Down Expand Up @@ -382,6 +386,8 @@ export async function runDiffCommand(options) {
const entriesWithOverrides = applyPolicySeverityOverrides(allEntries, policy);
// Filter diff-noise entries (formatting/reorder-only changes)
const filteredEntries = detectDiffNoise(entriesWithOverrides);
// Enrich entries with traceability fields (Phase 2)
const enrichedEntries = sortDiffEntries(filteredEntries.map((e) => enrichDiffEntry(e, "diff")));
// Build output
const output = {
schemaVersion: "1.0.0",
Expand All @@ -402,20 +408,20 @@ export async function runDiffCommand(options) {
strippedPaths: normalizedContract.metadata.strippedPaths,
},
summary: {
totalChanges: filteredEntries.length,
totalChanges: enrichedEntries.length,
byType: {
added: filteredEntries.filter((e) => e.type === "added").length,
removed: filteredEntries.filter((e) => e.type === "removed").length,
modified: filteredEntries.filter((e) => e.type === "modified").length,
renamed: filteredEntries.filter((e) => e.type === "renamed").length,
added: enrichedEntries.filter((e) => e.type === "added").length,
removed: enrichedEntries.filter((e) => e.type === "removed").length,
modified: enrichedEntries.filter((e) => e.type === "modified").length,
renamed: enrichedEntries.filter((e) => e.type === "renamed").length,
},
bySeverity: {
error: filteredEntries.filter((e) => e.severity === "error").length,
warning: filteredEntries.filter((e) => e.severity === "warning").length,
info: filteredEntries.filter((e) => e.severity === "info").length,
error: enrichedEntries.filter((e) => e.severity === "error").length,
warning: enrichedEntries.filter((e) => e.severity === "warning").length,
info: enrichedEntries.filter((e) => e.severity === "info").length,
},
},
entries: sortDiffEntries(filteredEntries),
entries: enrichedEntries,
repro: {
command: `interfacectl diff --contract "${contractPath}" --root "${workspaceRoot}"`,
},
Expand All @@ -435,10 +441,10 @@ export async function runDiffCommand(options) {
}
}
}
// Determine exit code based on filtered entries and severity
const exitCode = getDiffExitCode(filteredEntries, exitCodeVersion);
// Determine exit code based on enriched entries and severity
const exitCode = getDiffExitCode(enrichedEntries, exitCodeVersion);
// Print deprecation warning for v1 when diffs exist
if (exitCodeVersion === "v1" && filteredEntries.length > 0) {
if (exitCodeVersion === "v1" && enrichedEntries.length > 0) {
process.stderr.write("Deprecation: default exit codes will change. Use --exit-codes v2 to opt in.\n");
}
return finalize(exitCode, output);
Expand Down
2 changes: 1 addition & 1 deletion packages/interfacectl-cli/dist/commands/enforce.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 7 additions & 4 deletions packages/interfacectl-cli/dist/commands/enforce.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { loadPolicy, loadDefaultPolicy } from "../utils/policy.js";
import { canAutofix, applyFix } from "../utils/autofix.js";
import { generateUnifiedPatch, generateJsonPatch, } from "../utils/file-mutator.js";
import { getExitCodeVersion } from "../utils/exit-codes.js";
import { enrichFixEntry } from "../utils/traceability.js";
async function writeFileWithParents(filePath, contents) {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, contents, "utf8");
Expand Down Expand Up @@ -211,24 +212,26 @@ export async function runEnforceCommand(options) {
const rule = matchingRules[0];
const fix = applyFix(entry, rule);
if (!fix) {
skipped.push({
const skipEntry = {
ruleId: rule.id,
path: entry.path,
oldValue: entry.observedValue,
newValue: entry.contractValue,
confidence: 0.5,
});
};
skipped.push(enrichFixEntry(skipEntry, entry, "enforce"));
continue;
}
// Apply fix if not dry run
const enrichedFix = enrichFixEntry(fix, entry, "enforce");
if (!dryRun && mode === "fix") {
// In fix mode, actually apply the fix
// For now, we'll mark it as applied (actual file mutation would happen here)
applied.push(fix);
applied.push(enrichedFix);
}
else {
// In pr mode or dry run, just collect fixes
applied.push(fix);
applied.push(enrichedFix);
}
}
// Generate patch for pr mode
Expand Down
Loading