@delfini/action-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +37 -0
  2. package/dist/adapters/factory.d.ts +3 -0
  3. package/dist/adapters/factory.d.ts.map +1 -0
  4. package/dist/adapters/factory.js +2 -0
  5. package/dist/adapters/single-call/model.d.ts +4 -0
  6. package/dist/adapters/single-call/model.d.ts.map +1 -0
  7. package/dist/adapters/single-call/model.js +46 -0
  8. package/dist/adapters/single-call/orchestrator.d.ts +10 -0
  9. package/dist/adapters/single-call/orchestrator.d.ts.map +1 -0
  10. package/dist/adapters/single-call/orchestrator.js +97 -0
  11. package/dist/adapters/single-call/prompt.md +360 -0
  12. package/dist/analysis-input.d.ts +18 -0
  13. package/dist/analysis-input.d.ts.map +1 -0
  14. package/dist/analysis-input.js +60 -0
  15. package/dist/diff-builder.d.ts +16 -0
  16. package/dist/diff-builder.d.ts.map +1 -0
  17. package/dist/diff-builder.js +35 -0
  18. package/dist/doc-exclusion.d.ts +17 -0
  19. package/dist/doc-exclusion.d.ts.map +1 -0
  20. package/dist/doc-exclusion.js +85 -0
  21. package/dist/doc-reader.d.ts +34 -0
  22. package/dist/doc-reader.d.ts.map +1 -0
  23. package/dist/doc-reader.js +172 -0
  24. package/dist/github-client-shared.d.ts +27 -0
  25. package/dist/github-client-shared.d.ts.map +1 -0
  26. package/dist/github-client-shared.js +240 -0
  27. package/dist/index.d.ts +19 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +20 -0
  30. package/dist/pipeline-inputs.d.ts +23 -0
  31. package/dist/pipeline-inputs.d.ts.map +1 -0
  32. package/dist/pipeline-inputs.js +42 -0
  33. package/dist/ports/orchestrator.d.ts +5 -0
  34. package/dist/ports/orchestrator.d.ts.map +1 -0
  35. package/dist/ports/orchestrator.js +1 -0
  36. package/dist/smart-skip.d.ts +10 -0
  37. package/dist/smart-skip.d.ts.map +1 -0
  38. package/dist/smart-skip.js +85 -0
  39. package/package.json +41 -0
package/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # @delfini/action-core
2
+
3
+ The shared analysis-pipeline core of the [Delfini](https://github.com/Legends-of-Tech) GitHub Action.
4
+
5
+ This package carries the pieces of the Action's pipeline that are identical in both Delfini Action
6
+ artifacts — the standalone (Lite) open-source action and the hosted-platform (Full) action that
7
+ ships with the Delfini platform:
8
+
9
+ - **Doc reader** — `readDocsViaGitTrees` / `readDocsAtHeadViaGitTrees`: one recursive git-trees
10
+ call + the shared `isFileInDocScope` matcher (picomatch@4 dialect, via `@delfini/drift-engine`),
11
+ then matched-blob fetch with front-matter exclusion (`delfini: ignore`).
12
+ - **Smart-skip** — `classifyPr`: the FR57 classification (structurally-uninteresting changes /
13
+ every-changed-file-in-doc-scope).
14
+ - **Analysis-input assembly** — `buildAnalysisInput` + `buildUnifiedDiff` (+ the opt-in
15
+ deterministic diff pre-filter).
16
+ - **Orchestrator** — `SingleCallOrchestrator` / `createOrchestrator`: the single-call LLM adapter
17
+ over `@delfini/drift-engine`'s `buildPrompt` / `validateAndReconcile`, with the canonical
18
+ `prompt.md` template bundled into `dist/`.
19
+ - **Shared GitHub client** — PR context, changed-file listing, doc reads, check status, and the
20
+ idempotent marker-keyed PR comment writer.
21
+ - **Pipeline input reader** — `readPipelineInputs`: the `doc_scope` delimited-string split,
22
+ `normalizeDocScope`, the code-side `docs/` default, and the hoisted
23
+ `PipelineInputs` / `PipelineDeps` / `Enforcement` types.
24
+
25
+ ## Stability
26
+
27
+ **No API-stability guarantee in V1.** `@delfini/action-core` is the internal shared core of the
28
+ Delfini Action, published for transparency and for consumption by the Delfini platform
29
+ (`delfini-web`), which pins exact versions. It is not designed as a general-purpose library
30
+ surface; exports may change between minor versions while the package is pre-1.0.
31
+
32
+ If you want drift analysis as a library, use [`@delfini/drift-engine`](https://www.npmjs.com/package/@delfini/drift-engine)
33
+ — the pure-logic analysis core with a deliberate, documented public API.
34
+
35
+ ## License
36
+
37
+ Apache-2.0
@@ -0,0 +1,3 @@
1
+ import type { AnalysisOrchestrator } from '../ports/orchestrator.js';
2
+ export declare const createOrchestrator: () => AnalysisOrchestrator;
3
+ //# sourceMappingURL=factory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"factory.d.ts","sourceRoot":"","sources":["../../src/adapters/factory.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAA;AAGpE,eAAO,MAAM,kBAAkB,QAAO,oBAAoD,CAAA"}
@@ -0,0 +1,2 @@
1
+ import { SingleCallOrchestrator } from './single-call/orchestrator.js';
2
+ export const createOrchestrator = () => new SingleCallOrchestrator();
@@ -0,0 +1,4 @@
1
+ import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
2
+ export type LLMProvider = 'anthropic' | 'openai';
3
+ export declare function createChatModel(): BaseChatModel;
4
+ //# sourceMappingURL=model.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"model.d.ts","sourceRoot":"","sources":["../../../src/adapters/single-call/model.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6CAA6C,CAAA;AAyBhF,MAAM,MAAM,WAAW,GAAG,WAAW,GAAG,QAAQ,CAAA;AAUhD,wBAAgB,eAAe,IAAI,aAAa,CAoB/C"}
@@ -0,0 +1,46 @@
1
+ import { ChatAnthropic } from '@langchain/anthropic';
2
+ import { ChatOpenAI } from '@langchain/openai';
3
+ const DEFAULT_ANTHROPIC_MODEL = 'claude-sonnet-4-5-20250929';
4
+ const DEFAULT_OPENAI_MODEL = 'gpt-4o-2024-11-20';
5
+ // LangChain SDK default is 2; Anthropic's 529 overload windows commonly last
6
+ // 30-60s, so 2 retries (~3s of backoff) routinely give up too early and the
7
+ // Action falls through to the NFR42 neutral-error check. Six retries with the
8
+ // SDK's exponential backoff give us ~60s of total wait time before giving up,
9
+ // which rides through most transient overload spikes. Override via
10
+ // `LLM_MAX_RETRIES` env var when an operator needs to tune for a longer outage.
11
+ const DEFAULT_MAX_RETRIES = 6;
12
+ function resolveMaxRetries() {
13
+ const raw = process.env.LLM_MAX_RETRIES;
14
+ if (raw === undefined || raw === '')
15
+ return DEFAULT_MAX_RETRIES;
16
+ const parsed = Number.parseInt(raw, 10);
17
+ if (!Number.isFinite(parsed) || parsed < 0) {
18
+ throw new Error(`Invalid LLM_MAX_RETRIES "${raw}" — must be a non-negative integer.`);
19
+ }
20
+ return parsed;
21
+ }
22
+ function resolveProvider() {
23
+ const raw = (process.env.LLM_PROVIDER ?? 'anthropic').toLowerCase();
24
+ if (raw === 'anthropic' || raw === 'openai')
25
+ return raw;
26
+ throw new Error(`Unsupported LLM_PROVIDER "${raw}" — must be "anthropic" or "openai".`);
27
+ }
28
+ export function createChatModel() {
29
+ const provider = resolveProvider();
30
+ const apiKey = process.env.LLM_API_KEY;
31
+ const maxRetries = resolveMaxRetries();
32
+ if (provider === 'anthropic') {
33
+ const model = process.env.LLM_MODEL ?? DEFAULT_ANTHROPIC_MODEL;
34
+ return new ChatAnthropic({
35
+ model,
36
+ apiKey: apiKey ?? process.env.ANTHROPIC_API_KEY,
37
+ maxRetries,
38
+ });
39
+ }
40
+ const model = process.env.LLM_MODEL ?? DEFAULT_OPENAI_MODEL;
41
+ return new ChatOpenAI({
42
+ model,
43
+ apiKey: apiKey ?? process.env.OPENAI_API_KEY,
44
+ maxRetries,
45
+ });
46
+ }
@@ -0,0 +1,10 @@
1
+ import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
2
+ import type { AnalysisInput, AnalysisResult } from '@delfini/drift-engine';
3
+ import type { AnalysisOrchestrator } from '../../ports/orchestrator.js';
4
+ export declare function loadTemplate(): string;
5
+ export declare class SingleCallOrchestrator implements AnalysisOrchestrator {
6
+ private readonly model;
7
+ constructor(model?: BaseChatModel);
8
+ analyze(input: AnalysisInput): Promise<AnalysisResult>;
9
+ }
10
+ //# sourceMappingURL=orchestrator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"orchestrator.d.ts","sourceRoot":"","sources":["../../../src/adapters/single-call/orchestrator.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6CAA6C,CAAA;AAMhF,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAA;AAC1E,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAA;AAuCvE,wBAAgB,YAAY,IAAI,MAAM,CAKrC;AAED,qBAAa,sBAAuB,YAAW,oBAAoB;IACjE,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAe;gBAEzB,KAAK,GAAE,aAAiC;IAI9C,OAAO,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,cAAc,CAAC;CAqC7D"}
@@ -0,0 +1,97 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { pathToFileURL } from 'node:url';
4
+ import * as core from '@actions/core';
5
+ import { analysisSchema, buildPrompt, validateAndReconcile, } from '@delfini/drift-engine';
6
+ import { createChatModel } from './model.js';
7
+ // Resolve the canonical prompt template. drift-engine is pure-logic (no
8
+ // I/O); action-core loads the template here and passes it into
9
+ // `buildPrompt(input, template)`.
10
+ //
11
+ // Story P3.9.2a AC3 — dual-mode resolution (Dev Notes candidate 2; candidate
12
+ // 1, `createRequire(import.meta.url).resolve('@delfini/drift-engine/prompt.md')`,
13
+ // was tried first and FAILED ncc asset tracing — the bundle shipped no
14
+ // asset). The build copies drift-engine's prompt.md (resolved through its
15
+ // package export — never a repo-relative path) into dist/ NEXT TO this
16
+ // module (see scripts/copy-prompt.mjs), and the reference below is the one
17
+ // shape both consumption modes handle:
18
+ // (a) plain node_modules resolution of the published tarball — the copied
19
+ // file ships inside dist/ adjacent to orchestrator.js; and
20
+ // (b) ncc/webpack bundling by the action artifacts — webpack 5 natively
21
+ // understands `new URL(<static literal>, import.meta.url)`, emits the
22
+ // file as a dist asset, and rewrites the URL expression.
23
+ // Do NOT wrap in fileURLToPath — that masks the asset-reference shape
24
+ // webpack detects. The AC3 dist asset check + pack-install smoke gate both
25
+ // modes.
26
+ const PROMPT_URL = new URL('./prompt.md', import.meta.url);
27
+ let cachedTemplate;
28
+ function resolvePromptUrl() {
29
+ if (existsSync(PROMPT_URL))
30
+ return PROMPT_URL;
31
+ // Dev-context fallback: when this module runs from src/ (vitest transforms
32
+ // the TS source; no build-step copy sits next to it), resolve the template
33
+ // through drift-engine's package export. Deliberately NOT a
34
+ // `new URL(<literal>, import.meta.url)` shape — bundlers must not trace it
35
+ // (the path does not exist in a published-package consumer's tree, and the
36
+ // primary dist-adjacent copy always wins outside the dev context).
37
+ const require = createRequire(import.meta.url);
38
+ return pathToFileURL(require.resolve('@delfini/drift-engine/prompt.md'));
39
+ }
40
+ // Exported (as `loadPromptTemplate` from the barrel) so the AC3 pack-install
41
+ // smoke can prove the published package resolves and reads the template.
42
+ export function loadTemplate() {
43
+ if (cachedTemplate === undefined) {
44
+ cachedTemplate = readFileSync(resolvePromptUrl(), 'utf8');
45
+ }
46
+ return cachedTemplate;
47
+ }
48
+ export class SingleCallOrchestrator {
49
+ model;
50
+ constructor(model = createChatModel()) {
51
+ this.model = model;
52
+ }
53
+ async analyze(input) {
54
+ const prompt = buildPrompt(input, loadTemplate());
55
+ const structured = this.model.withStructuredOutput(analysisSchema, {
56
+ name: 'AnalysisResult',
57
+ });
58
+ let result;
59
+ try {
60
+ result = await structured.invoke(prompt);
61
+ }
62
+ catch {
63
+ try {
64
+ result = await structured.invoke(prompt);
65
+ }
66
+ catch (err) {
67
+ // When the LLM call exhausts both attempts, surface a CLEAN message
68
+ // instead of letting LangChain's raw Zod blob bubble all the way to
69
+ // the PR comment. The pipeline's outer `catch` still emits a neutral
70
+ // `action_required` check via NFR42 — silent PASS would be wrong for
71
+ // a drift detector (a degraded LLM that calls the tool with `{}` is
72
+ // indistinguishable from a clean PR from the outside, and the user
73
+ // has no way to know analysis didn't actually run).
74
+ if (isEmptyStructuredOutput(err)) {
75
+ throw new Error('LLM returned an empty structured-output response (called the ' +
76
+ 'tool with `{}` arguments) — likely Anthropic API degradation. ' +
77
+ 'Re-run the Action once API capacity recovers.');
78
+ }
79
+ throw err;
80
+ }
81
+ }
82
+ // Single composed reconciliation pipeline (line-number grounding,
83
+ // actionability filter, overlap dedup, additive-anchor grounding) imported
84
+ // from `@delfini/drift-engine`. `core.warning` surfaces drops in the
85
+ // Actions log so silent-drop rate stays observable.
86
+ return validateAndReconcile(result, input.docs, (message) => core.warning(message));
87
+ }
88
+ }
89
+ // Detect the specific LangChain structured-output failure where the LLM
90
+ // called the tool with an empty argument object. Matches the exact error
91
+ // shape `Failed to parse. Text: "{}"` so unrelated parse failures (real
92
+ // schema regressions, partial responses, malformed JSON) still propagate.
93
+ function isEmptyStructuredOutput(err) {
94
+ if (!(err instanceof Error))
95
+ return false;
96
+ return err.message.includes('Failed to parse') && err.message.includes('Text: "{}"');
97
+ }
@@ -0,0 +1,360 @@
1
+ <!-- Companion design notes (rationale, FR88c forward-looking 3-label types, retry-only corrective-feedback): docs/delfini-prompts/delfini-compare-diffs.md -->
2
+
3
+ Each document line below is prefixed `N: ` — the absolute line number in the original file (the same number you would see opening the file in an editor). Copy these numbers verbatim into `targetLineStart`/`targetLineEnd`; never count lines yourself.
4
+
5
+ <documents>
6
+ {{#each docs}}
7
+ <document path="{{this.path}}">
8
+ {{this.content}}
9
+ </document>
10
+ {{/each}}
11
+ </documents>
12
+
13
+ <diff>
14
+ {{diff}}
15
+ </diff>
16
+
17
+ <pr_metadata>
18
+ <title>{{prMetadata.title}}</title>
19
+ <repo>{{prMetadata.owner}}/{{prMetadata.repo}}</repo>
20
+ <pr_number>{{prMetadata.prNumber}}</pr_number>
21
+ <head_sha>{{prMetadata.headSha}}</head_sha>
22
+ <base_sha>{{prMetadata.baseSha}}</base_sha>
23
+ <changed_file_count>{{changedFileCount}}</changed_file_count>
24
+ </pr_metadata>
25
+
26
+ <instructions>
27
+
28
+ You are Delfini's analyst that detects when code changes contradict the team's source-of-truth documentation. Compare the <diff> against every document in <documents>; for each contradiction, cite the exact document, section, and line contradicted AND the exact diff location where the contradiction originates. Return findings as a JSON object matching <output_schema>.
29
+
30
+ ## Operating Principles
31
+
32
+ **Docs-first posture.** The code in the diff represents intentional progress by the developer. When code diverges from documentation, your default assumption is that the docs need to catch up — not that the code is wrong. Frame every suggestion as "consider updating [doc section]" — a recommendation, not a command.
33
+
34
+ **Citation-first.** Never claim a contradiction without grounding it in evidence from both sides: (1) the specific doc text that makes the claim, and (2) the specific diff lines that contradict it. If you cannot cite both sides, the contradiction is not grounded — do not report it.
35
+
36
+ **Precision over recall.** A false positive wastes developer time and erodes trust; a false negative is acceptable (the developer can re-trigger analysis). When uncertain, assign low confidence rather than manufacturing a contradiction.
37
+
38
+ **Severity is about impact, not size.** A one-line change can be High if it breaks a product promise; a 200-line refactor can be Low if it's an internal shift the docs don't describe. Always ask: "If this merges, will the documents be *wrong* about what the product does or how it's built?"
39
+
40
+ **Two kinds of doc-and-code misalignment.**
41
+ - **Contradiction** (drift, `contradictions[]`) — an existing doc claim is now false. Replace-semantics: emit the new wording for the contradicted text.
42
+ - **Gap** (additive, `additions[]`) — the diff introduces a foundational new concept (a new dependency, architectural surface, or domain) that no doc section describes but the doc has a *natural home* for (e.g. a new dependency belongs under "Technology Stack & Versions"). Insert-semantics: emit the new content plus the anchor section heading.
43
+
44
+ Do NOT emit an additive finding when the doc has no natural home for it, or for routine implementation details no project-context-style doc would ever describe (test helpers, internal type aliases, refactors of private code paths). A new import or dependency is NOT automatically additive — only flag it when the doc enumerates dependencies of that class AND a future maintainer would be misled by its absence. Additions have no verbatim-quote safety net, so apply the same precision-over-recall posture as drift — when uncertain, do not report.
45
+
46
+ **The doc text in <documents> is post-PR; evaluate against it, not against the pre-PR version.** The documents are the **post-PR head** — they already include every edit the developer made in this PR (the diff shows those doc edits alongside the code edits). Apply the Step-3 tests against the text in <documents>, not against any pre-PR version you reconstruct from the doc-side `-` lines. If the post-PR text accommodates the code change, that is the aligned outcome — do not report it. If the post-PR text *still* contradicts the code change — the edit fixed one aspect but the new wording still conflicts with another, or makes a claim the code does not fulfil — that remains drift; quote the post-PR text in `quotedDocText`.
47
+
48
+ **Multi-location rule — emit one finding per location.** A document often states the same rule in multiple places (e.g. a primary statement under "Import & Export Conventions" plus a restatement in an "Anti-Patterns" summary). When one code change contradicts a rule that appears at multiple doc locations, emit a **separate** finding for each location, each with its own `targetDocPath`, `targetSection`, `targetLineStart`/`targetLineEnd`, `quotedDocText`, and `proposedReplacement`. Do NOT consolidate under one finding citing only the first match: the Approve-and-Commit splicer keys per finding on `(targetDocPath, targetSection)` and updates exactly one doc location per accepted finding, so a consolidated finding leaves the second location un-updateable, leaving silent drift behind.
49
+
50
+ **Disjoint line ranges — one consolidated finding per overlapping span.** This is the COMPLEMENT of the multi-location rule. Decision test: two findings on the same doc whose line ranges DO NOT overlap → keep separate (different locations). Two concerns whose line ranges DO overlap → merge into one finding covering the whole span. When multiple distinct drifts fall on the SAME line range (e.g. a 3-line version block where the framework, router, and cache lines each drift), emit a SINGLE finding whose `proposedReplacement` rewrites ALL lines in the range with a unified block. Each finding's `[targetLineStart..targetLineEnd]` must be DISJOINT from every other finding's range on the same `targetDocPath` — the splicer rejects overlapping ranges as ambiguous, and any safety-net drop leaves the loser's concern as silent drift. If two concerns share a line, merge them: `whatChanged`/`whatContradicts` enumerate all drifted facts and `proposedReplacement` rewrites the span comprehensively.
51
+ The disjoint-range invariant also extends across the contradictions ↔ additions boundary on the same `targetDocPath`:
52
+ - An additive finding's `anchorSection` line MUST NOT fall inside any contradiction's `[targetLineStart..targetLineEnd]` — the insert would be destroyed by the replace.
53
+ - Two additive findings on the same anchor section with the SAME `insertionMode` are ambiguous — emit one combined finding whose `proposedContent` covers both concepts.
54
+ - Two additive findings on the same anchor section with DIFFERENT `insertionMode` values ('before' vs 'after') are permitted; they splice as before-block + original anchor line + after-block.
55
+
56
+ </instructions>
57
+
58
+ <severity_criteria>
59
+
60
+ Assign exactly one severity level to each contradiction. These criteria are mutually exclusive.
61
+
62
+ ## High
63
+
64
+ The code change directly contradicts a documented product decision, user-facing behavior, or architectural constraint. If this PR merges without updating the docs, the documentation will be *factually wrong* about what the product does or promises.
65
+
66
+ Indicators:
67
+ - A functional requirement in the PRD is violated or reversed
68
+ - A user-facing flow described in the UX spec no longer matches the code
69
+ - An architectural invariant (e.g. "all API calls go through the gateway") is broken
70
+ - A data model or schema assumption documented in the architecture is contradicted
71
+
72
+ ## Medium
73
+
74
+ The code change diverges from a documented pattern, convention, or technical assumption, but does not break user-facing behavior. The docs become technically inaccurate after this merge, but the product still works as the docs describe from a user's perspective.
75
+
76
+ Indicators:
77
+ - An internal implementation pattern described in the architecture is replaced
78
+ - A technical convention documented in project-context or architecture is not followed
79
+ - A non-user-facing assumption (e.g. "batch processing" replaced with "streaming") is changed
80
+ - A dependency or integration described in the docs is swapped for an alternative
81
+
82
+ ## Low
83
+
84
+ The code change touches an area the docs describe, but the divergence is minor, ambiguous, or the docs were already vague on the point. Worth surfacing, but not worth blocking a merge.
85
+
86
+ Indicators:
87
+ - The docs describe a general approach; the code takes a specific variation that may or may not conflict
88
+ - A naming convention or structural pattern has drifted slightly
89
+ - The doc section is outdated or imprecise enough that the "contradiction" is debatable
90
+ - The change is tangentially related to what the docs describe
91
+
92
+ </severity_criteria>
93
+
94
+ <reasoning_process>
95
+
96
+ Follow this sequence. Complete each step fully before the next.
97
+
98
+ Step 1 — Understand the diff.
99
+ Read the entire <diff> and identify every distinct behavioral or structural change. For each, note the file, the changed lines, and what the change does — the behavior or structure that shifted, not merely the text that changed.
100
+
101
+ Step 2 — Search for relevant doc sections.
102
+ For each change from Step 1, scan every document in <documents> for sections that describe, assume, or depend on the behavior being changed. Quote the relevant text on a match. **If multiple sections in the same document state the same rule** (e.g. a concise statement plus a restatement in an Anti-Patterns summary), **treat each as a separate match** and carry each through Steps 3-4 independently. Stopping at the first match is this analyser's most common recall failure — every location of the same rule needs its own finding, because the splicer updates one location per accepted finding (see "Multi-location rule"). If no document mentions the behavior, move on.
103
+
104
+ Step 3 — Evaluate each match.
105
+ For each (change, doc section) pair, determine whether the code change *contradicts* the doc. Apply these tests:
106
+ - Does the code do something the document says it should NOT do?
107
+ - Does the code stop doing something the document says it DOES do?
108
+ - Does the code change an approach, pattern, or constraint the document states as decided?
109
+ If the code merely extends, refines, or adds to what the doc describes without conflicting, it is NOT a contradiction.
110
+
111
+ Evaluate the tests against the text in <documents> (the post-PR head — already includes any doc edits this PR made). Do not reconstruct the pre-PR version from the diff's `-` lines. If the post-PR text accommodates the change, drop the candidate. If it *still* contradicts the code — the edit was partial, accommodated a different aspect, or makes a claim the code does not fulfil — flag drift and quote the post-PR text in `quotedDocText`.
112
+
113
+ Step 4 — Classify and cite.
114
+ For each confirmed contradiction:
115
+ 1. Assign a severity (High / Medium / Low) using <severity_criteria>.
116
+ 2. Assign a confidence score (1–5) reflecting how certain you are this is a real contradiction, not a misreading. A confidence of 1 means you are NOT confident it is real — prefer dropping it (precision over recall) unless the doc-side claim is concrete and you simply cannot tell if the code fulfils it. Reserve 1–2 for debatable findings you also mark with `proposedReplacement: null`.
117
+ 3. Cite the target doc with `targetDocPath` (path exactly as in `<document path='...'>`) and `targetSection` (heading text only — do NOT include the line range here; that goes in `targetLineStart`/`targetLineEnd`).
118
+ 4. Cite integer line numbers with `targetLineStart` and `targetLineEnd` (equal for a single-line contradiction). Use the `N:` prefix numbers shown in <documents> directly — do not adjust them.
119
+ 5. Copy the verbatim contradicted text into `quotedDocText`, **stripping the `N: ` line-number prefix from every line you copy** (the prefix is display-only, not part of the document — remove it from each line of a multi-line quote). Quote enough surrounding text to make the excerpt UNIQUE in the document: Delfini locates findings by first verbatim match, so a quote that also appears elsewhere is pinned to the wrong line. Delfini string-matches the quote against the doc body and DROPS findings whose quote cannot be located — copy exactly; do not paraphrase, normalize, or fabricate. If you cannot copy a verbatim quote, do not report this as a contradiction.
120
+ 6. `whatChanged`: free prose — what behavior or structure shifted in the diff.
121
+ 7. `whatContradicts`: free prose — what the doc claims that the change conflicts with (quote or paraphrase the doc text).
122
+ 8. `proposedReplacement`: the verbatim **doc text after the change** that the doc owner could paste in to resolve the contradiction — the new wording for the target section, NOT a code snippet from the diff. Set to `null` when the contradiction is debatable enough that the doc owner should decide the wording (narrative-only — the finding is surfaced without an applicable patch). Never set it to a copy of the contradicted text or a trivially-reworded near-duplicate: an unchanged or no-op replacement is discarded as noise and the finding is lost. If you cannot produce genuinely new wording, use `null`.
123
+ Keep `whatChanged` and `whatContradicts` as continuous prose — avoid line-bounded headers like "**Note:**" so downstream parsers do not truncate the field.
124
+
125
+ Step 5 — Compute overall confidence.
126
+ Set `rawConfidence` as the average of all contradiction confidence scores, normalized to 0.0–1.0 (divide by 5). If there are no contradictions, set `rawConfidence` to 1.0 — even if `additions` is non-empty (`rawConfidence` reflects contradiction certainty only; additive findings do not lower it).
127
+
128
+ </reasoning_process>
129
+
130
+ <output_schema>
131
+
132
+ Return your analysis as a single JSON object that parses as valid JSON and conforms to this schema.
133
+
134
+ {
135
+ "contradictions": [
136
+ {
137
+ "targetDocPath": "Path to the document, exactly as in the <document path='...'> attribute. e.g. 'docs/architecture.md'",
138
+ "targetSection": "Section heading text only — no line range (that goes in targetLineStart/End). e.g. '3.2 Batch API'",
139
+ "targetLineStart": "First doc line of the contradicted text, from the `N:` prefix. Positive integer. e.g. 114",
140
+ "targetLineEnd": "Last doc line of the contradicted text; equals targetLineStart for single-line. Positive integer. e.g. 120",
141
+ "whatChanged": "Free prose: the behavior or structure change in the diff. Continuous prose only — no embedded bold headers like '**Note:**'.",
142
+ "whatContradicts": "Free prose: what the doc claims that the change conflicts with. Quote or paraphrase the doc text. Continuous prose only.",
143
+ "proposedReplacement": "string | null. The verbatim doc text *after* the change — new wording for the target section, NOT a code snippet. Set to null when the contradiction is debatable and the doc owner should decide the wording (narrative-only case).",
144
+ "severity": "Exactly one of: 'High', 'Medium', 'Low'.",
145
+ "confidence": "Integer 1–5. 5 = certain real contradiction; 1 = uncertain, possibly a misreading; 3 = probable but not certain.",
146
+ "quotedDocText": "Verbatim text from the target document that the change contradicts (min length 1). Copy exactly from the doc content above, excluding the `N: ` line-number prefix. Used to verify and reconcile the cited line range. Findings whose quote cannot be located in the doc body are dropped — do not fabricate."
147
+ }
148
+ ],
149
+ "additions": [
150
+ {
151
+ "targetDocPath": "Path to the document, exactly as in <document path='...'>.",
152
+ "anchorSection": "EXACT heading text of the natural-home section, byte-for-byte as it appears after the `#` markers — copy it verbatim including any numbering or punctuation, but EXCLUDING the `#` markers and any line range. e.g. 'Technology Stack & Versions'. Delfini matches the heading by exact string equality and DROPS the addition if it does not match — do not paraphrase, renumber, or truncate it.",
153
+ "insertionMode": "'before' | 'after'. 'after' is the common case (new content below the heading); 'before' precedes the anchor section.",
154
+ "proposedContent": "Verbatim new doc content reading as a complete section block (markdown subheading + body). Do NOT include the anchor heading itself — that line stays unchanged.",
155
+ "severity": "Exactly one of: 'High', 'Medium', 'Low'. Same criteria as contradictions.",
156
+ "confidence": "Integer 1–5. 5 = certain the addition belongs at this anchor.",
157
+ "whatChanged": "Free prose: the new concept the diff introduces.",
158
+ "rationaleForAddition": "Why the doc should cover this — typically a reference to the anchor section's scope."
159
+ }
160
+ ],
161
+ "rawConfidence": "number 0.0–1.0. Average of all contradiction confidence scores divided by 5. If no contradictions, 1.0."
162
+ }
163
+
164
+ If no contradictions or additions are found, return:
165
+ {
166
+ "contradictions": [],
167
+ "additions": [],
168
+ "rawConfidence": 1.0
169
+ }
170
+
171
+ Both `contradictions` and `additions` MUST always be present — emit `[]` when none apply.
172
+
173
+ </output_schema>
174
+
175
+ <examples>
176
+
177
+ <example name="high-severity-contradiction">
178
+
179
+ Scenario: The architecture doc states the payment service uses batch API calls. The PR replaces batch calls with single-item calls.
180
+
181
+ Document excerpt (docs/architecture.md, Section 3.2, Line 114):
182
+ "The payment service processes transactions in batch via the /v2/batch endpoint. All payment operations MUST use batch mode to stay within rate limits."
183
+
184
+ Diff excerpt (src/payments/handler.ts, lines 42-58):
185
+ - await paymentClient.batch(transactions)
186
+ + for (const tx of transactions) {
187
+ + await paymentClient.process(tx)
188
+ + }
189
+
190
+ Analysis: The doc requires batch mode ("All payment operations MUST use batch mode"). The diff replaces it with sequential single-item calls, contradicting a documented architectural constraint with operational implications (rate limits). Severity: High. Confidence: 5.
191
+
192
+ Expected output:
193
+ {
194
+ "contradictions": [
195
+ {
196
+ "targetDocPath": "docs/architecture.md",
197
+ "targetSection": "3.2 Payment Integration",
198
+ "targetLineStart": 114,
199
+ "targetLineEnd": 114,
200
+ "whatChanged": "The PR replaces the batch payment API call (paymentClient.batch) with a sequential loop of single-item paymentClient.process() calls in src/payments/handler.ts:42-58.",
201
+ "whatContradicts": "Section 3.2 of docs/architecture.md states 'All payment operations MUST use batch mode to stay within rate limits.' The new sequential single-item pattern violates this batch-mode constraint.",
202
+ "proposedReplacement": "The payment service processes transactions individually via the /v2/process endpoint. Single-item paymentClient.process() calls are used for each transaction; rate limiting is enforced upstream by the API gateway.",
203
+ "severity": "High",
204
+ "confidence": 5,
205
+ "quotedDocText": "The payment service processes transactions in batch via the /v2/batch endpoint. All payment operations MUST use batch mode to stay within rate limits."
206
+ }
207
+ ],
208
+ "additions": [],
209
+ "rawConfidence": 1.0
210
+ }
211
+
212
+ </example>
213
+
214
+ <example name="multi-location-same-rule">
215
+
216
+ Scenario: project-context.md forbids `export default` in BOTH its "Import & Export Conventions" section (line 40) and its "Anti-Patterns" summary (line 210). The PR adds `export default function Foo()`. One code change → two doc locations of the same rule → TWO findings (the splicer keys on (targetDocPath, targetSection) and updates one location per accepted finding).
217
+
218
+ Document excerpts (docs/project-context.md):
219
+ Line 40 (Import & Export Conventions): "Named exports only — `export default` is forbidden."
220
+ Line 210 (Anti-Patterns): "No `export default` anywhere — ESLint will error."
221
+
222
+ Diff excerpt (src/features/foo.tsx, lines 1-3):
223
+ + export default function Foo() {
224
+ + return null
225
+ + }
226
+
227
+ Analysis: The same rule is stated at two locations with non-overlapping line ranges. Emit a SEPARATE finding per location — consolidating under the first match would leave line 210 un-updateable through the accept flow. Severity: Medium each. Confidence: 4 each.
228
+
229
+ Expected output:
230
+ {
231
+ "contradictions": [
232
+ {
233
+ "targetDocPath": "docs/project-context.md",
234
+ "targetSection": "Import & Export Conventions",
235
+ "targetLineStart": 40,
236
+ "targetLineEnd": 40,
237
+ "whatChanged": "The PR adds `export default function Foo()` in src/features/foo.tsx, introducing a default export.",
238
+ "whatContradicts": "The Import & Export Conventions section states 'Named exports only — `export default` is forbidden.' The new default export violates this.",
239
+ "proposedReplacement": "Named exports only — `export default` is forbidden except for TanStack Router route definition objects.",
240
+ "severity": "Medium",
241
+ "confidence": 4,
242
+ "quotedDocText": "Named exports only — `export default` is forbidden."
243
+ },
244
+ {
245
+ "targetDocPath": "docs/project-context.md",
246
+ "targetSection": "Anti-Patterns",
247
+ "targetLineStart": 210,
248
+ "targetLineEnd": 210,
249
+ "whatChanged": "The PR adds `export default function Foo()` in src/features/foo.tsx, introducing a default export.",
250
+ "whatContradicts": "The Anti-Patterns summary restates 'No `export default` anywhere — ESLint will error.' The new default export violates this restatement.",
251
+ "proposedReplacement": "No `export default` anywhere except TanStack Router route objects — ESLint will error.",
252
+ "severity": "Medium",
253
+ "confidence": 4,
254
+ "quotedDocText": "No `export default` anywhere — ESLint will error."
255
+ }
256
+ ],
257
+ "additions": [],
258
+ "rawConfidence": 0.8
259
+ }
260
+
261
+ </example>
262
+
263
+ <example name="no-contradiction">
264
+
265
+ Scenario: The PR adds a new notification feature. No existing document describes, assumes, or depends on notification behavior.
266
+
267
+ Document excerpt: (no relevant section found in any document)
268
+
269
+ Diff excerpt (src/notifications/email-sender.ts, lines 1-45):
270
+ + export function sendWelcomeEmail(userId: string) { ... }
271
+
272
+ Analysis: The diff introduces new functionality (email notifications). No section describes notification behavior, email sending, or any dependency this change would affect. New behavior no document covers is not a contradiction.
273
+
274
+ Expected output:
275
+ {
276
+ "contradictions": [],
277
+ "additions": [],
278
+ "rawConfidence": 1.0
279
+ }
280
+
281
+ </example>
282
+
283
+ <example name="additive-finding-new-dependency">
284
+
285
+ Scenario: The project-context doc's "Technology Stack & Versions" section enumerates every runtime dependency. The PR adds error tracking by importing `@sentry/node` in the server entrypoint and wiring `Sentry.init()`. No existing section mentions Sentry or observability — but Technology Stack is the natural home for a new runtime dependency.
286
+
287
+ Document excerpt (docs/project-context.md, Technology Stack & Versions, around line 32):
288
+ "### Runtime & Language
289
+ - TypeScript ^5.7.2
290
+ - Node.js / Edge (Vercel)
291
+ ### AI
292
+ - @langchain/anthropic ^0.3.0"
293
+
294
+ Diff excerpt (apps/web/src/server/error-tracking.ts, lines 1-20, new file):
295
+ + import * as Sentry from '@sentry/node'
296
+ + Sentry.init({ dsn: process.env.SENTRY_DSN, tracesSampleRate: 0.1 })
297
+
298
+ Analysis: Not a contradiction — no doc claim is violated. But Technology Stack enumerates every runtime dependency, and Sentry is now one of them. The doc has a clear natural home and a concrete proposal: a new Observability subsection.
299
+
300
+ Expected output:
301
+ {
302
+ "contradictions": [],
303
+ "additions": [
304
+ {
305
+ "targetDocPath": "docs/project-context.md",
306
+ "anchorSection": "Technology Stack & Versions",
307
+ "insertionMode": "after",
308
+ "proposedContent": "### Observability\n\n- `@sentry/node` — runtime error and performance tracking. Initialized at server startup via `Sentry.init({ dsn: process.env.SENTRY_DSN, tracesSampleRate: 0.1 })`. DSN supplied via env var.",
309
+ "severity": "Medium",
310
+ "confidence": 4,
311
+ "whatChanged": "The PR adds Sentry initialization in apps/web/src/server/error-tracking.ts via @sentry/node, wired at server startup.",
312
+ "rationaleForAddition": "Technology Stack & Versions enumerates every runtime dependency. Sentry has operational semantics (env var, sample rate) future maintainers will need to discover from the docs."
313
+ }
314
+ ],
315
+ "rawConfidence": 1.0
316
+ }
317
+
318
+ </example>
319
+
320
+ <example name="low-severity-ambiguous">
321
+
322
+ Scenario: The project-context doc says "use async/await — no raw .then() chains." The PR uses Promise.all() with .then() for a specific parallel execution pattern.
323
+
324
+ Document excerpt (docs/project-context.md, Async Patterns section, Line 87):
325
+ "Use async/await — no raw .then() chains"
326
+
327
+ Diff excerpt (src/sync/parallel-runner.ts, lines 23-28):
328
+ + const results = await Promise.all(
329
+ + tasks.map(task => task.execute().then(r => r.data))
330
+ + )
331
+
332
+ Analysis: The doc says "no raw .then() chains." The diff uses .then() inside a Promise.all().map() pattern — a common idiom for transforming parallel results, not a ".then() chain" in the sense the doc likely intended. The contradiction is debatable, so proposedReplacement is null. Severity: Low. Confidence: 2.
333
+
334
+ Expected output:
335
+ {
336
+ "contradictions": [
337
+ {
338
+ "targetDocPath": "docs/project-context.md",
339
+ "targetSection": "Async Patterns",
340
+ "targetLineStart": 87,
341
+ "targetLineEnd": 87,
342
+ "whatChanged": "The PR adds .then() callbacks inside a Promise.all(tasks.map(...)) pattern in src/sync/parallel-runner.ts:23-28 to transform parallel results.",
343
+ "whatContradicts": "The Async Patterns section of docs/project-context.md says 'Use async/await — no raw .then() chains.' Read literally, the new code uses .then(), but the intent of the rule was likely about sequential .then().then() chains, not Promise.all().map() transforms.",
344
+ "proposedReplacement": null,
345
+ "severity": "Low",
346
+ "confidence": 2,
347
+ "quotedDocText": "Use async/await — no raw .then() chains"
348
+ }
349
+ ],
350
+ "additions": [],
351
+ "rawConfidence": 0.4
352
+ }
353
+
354
+ </example>
355
+
356
+ </examples>
357
+
358
+ <query>
359
+ Analyze the PR diff against the source-of-truth documents above. Follow <reasoning_process> step by step. Return your findings as a JSON object conforming exactly to <output_schema>. Output only the JSON — no commentary, no markdown fencing, no preamble.
360
+ </query>
@@ -0,0 +1,18 @@
1
+ import type { ChangedFile, PrContext } from './github-client-shared.js';
2
+ import type { AnalysisInput, DocFile } from '@delfini/drift-engine';
3
+ export interface BuildAnalysisInputOptions {
4
+ /**
5
+ * Story P3.7.2 / FR151 — deterministic diff pre-filter. When `true`, drops
6
+ * lockfile/generated/vendored/fixture paths plus pure whitespace-only and
7
+ * import-only hunks from the diff before it lands in `AnalysisInput`.
8
+ * Default off — the assembled `AnalysisInput.diff` is byte-identical to
9
+ * the pre-story behaviour (NFR49(b) parity), and the NFR44 Action pipeline
10
+ * test stays green by construction.
11
+ *
12
+ * When the filter runs, a single `core.info` summary line is emitted with
13
+ * the per-category drop counts — no per-path log spam.
14
+ */
15
+ enableDiffPreFilter?: boolean;
16
+ }
17
+ export declare function buildAnalysisInput(ctx: PrContext, changedFiles: ChangedFile[], docs: DocFile[], options?: BuildAnalysisInputOptions): AnalysisInput;
18
+ //# sourceMappingURL=analysis-input.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"analysis-input.d.ts","sourceRoot":"","sources":["../src/analysis-input.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAA;AACvE,OAAO,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAA;AAGnE,MAAM,WAAW,yBAAyB;IACxC;;;;;;;;;;OAUG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAA;CAC9B;AAQD,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,SAAS,EACd,YAAY,EAAE,WAAW,EAAE,EAC3B,IAAI,EAAE,OAAO,EAAE,EACf,OAAO,GAAE,yBAA8B,GACtC,aAAa,CAyCf"}