gh-inari 0.1.0 → 0.2.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 (51) hide show
  1. package/README.md +42 -5
  2. package/dist/artifact.d.ts +45 -2
  3. package/dist/artifact.js +280 -21
  4. package/dist/artifact.js.map +1 -1
  5. package/dist/cli.js +131 -67
  6. package/dist/cli.js.map +1 -1
  7. package/dist/contract/constraints.d.ts +37 -0
  8. package/dist/contract/constraints.js +87 -0
  9. package/dist/contract/constraints.js.map +1 -0
  10. package/dist/contract/index.d.ts +1 -0
  11. package/dist/contract/index.js +1 -0
  12. package/dist/contract/index.js.map +1 -1
  13. package/dist/contract/ir.d.ts +34 -4
  14. package/dist/contract/ir.js +98 -7
  15. package/dist/contract/ir.js.map +1 -1
  16. package/dist/contract/issue-form.js +9 -6
  17. package/dist/contract/issue-form.js.map +1 -1
  18. package/dist/contract/schema.d.ts +2 -1
  19. package/dist/contract/schema.js +22 -51
  20. package/dist/contract/schema.js.map +1 -1
  21. package/dist/contract/validation.js +28 -36
  22. package/dist/contract/validation.js.map +1 -1
  23. package/dist/github/adapter.d.ts +13 -1
  24. package/dist/github/adapter.js +154 -3
  25. package/dist/github/adapter.js.map +1 -1
  26. package/dist/github/capability.d.ts +6 -0
  27. package/dist/github/capability.js +56 -0
  28. package/dist/github/capability.js.map +1 -0
  29. package/dist/github/errors.d.ts +6 -2
  30. package/dist/github/errors.js +6 -0
  31. package/dist/github/errors.js.map +1 -1
  32. package/dist/github/transport.d.ts +5 -0
  33. package/dist/github/transport.js +53 -8
  34. package/dist/github/transport.js.map +1 -1
  35. package/dist/github/types.d.ts +15 -12
  36. package/dist/github/types.js +0 -14
  37. package/dist/github/types.js.map +1 -1
  38. package/dist/governance.d.ts +50 -0
  39. package/dist/governance.js +244 -0
  40. package/dist/governance.js.map +1 -0
  41. package/dist/index.d.ts +2 -1
  42. package/dist/index.js +2 -1
  43. package/dist/index.js.map +1 -1
  44. package/dist/pr-policy.d.ts +13 -3
  45. package/dist/pr-policy.js +112 -27
  46. package/dist/pr-policy.js.map +1 -1
  47. package/dist/template-discovery.d.ts +13 -1
  48. package/dist/template-discovery.js +171 -43
  49. package/dist/template-discovery.js.map +1 -1
  50. package/gh-inari +36 -3
  51. package/package.json +8 -4
package/README.md CHANGED
@@ -22,6 +22,16 @@ To register the installed package as a GitHub CLI extension, pass its package di
22
22
  gh inari --help
23
23
  ```
24
24
 
25
+ A clean checkout is also a supported extension source:
26
+
27
+ ```bash
28
+ gh extension install yohn-jp/gh-inari
29
+ gh inari --help
30
+ ```
31
+
32
+ The repository launcher uses the committed build and installs its production
33
+ dependencies on first use when GitHub CLI installs directly from the repository.
34
+
25
35
  Inari uses the current `gh` authentication and repository context. It does not maintain a second credential store. Use `--repository owner/name` when the target repository is not the current checkout.
26
36
 
27
37
  ## Commands
@@ -40,6 +50,8 @@ gh inari issue validate <number> --template <template> --json
40
50
  gh inari pr validate <number> --template <template> --json
41
51
  gh inari issue explain <number> --template <template> --json
42
52
  gh inari pr explain <number> --template <template> --json
53
+ gh inari issue get <number> [--template <template>] --json
54
+ gh inari pr get <number> [--template <template>] --json
43
55
  ```
44
56
 
45
57
  `--from -` reads JSON from stdin. Create input uses an envelope when mutation metadata is needed:
@@ -56,9 +68,20 @@ The `fields` object is the semantic input contract shown by `schema`. Issue crea
56
68
 
57
69
  Schema and validation output is JSON. `--json` makes render and create output JSON as well. Validation failures return exit status `2`; usage errors return `1`; GitHub/transport failures return `3`. Error objects contain stable `code`, `path` where applicable, and ordered `violations`.
58
70
 
71
+ `issue get` and `pr get` are canonical-only v1 reads. They resolve the target
72
+ repository's default-branch governance, select the supported native template,
73
+ parse the existing artifact with the same parser and semantic validator as
74
+ `validate`/`explain`, and emit only canonical `fields` plus minimal artifact
75
+ metadata. Successful reads report `projection: "canonical"`; wrong-template,
76
+ unparseable, ambiguous, and semantically invalid artifacts report structured
77
+ diagnostics with `projection: "unavailable"` and never return guessed fields.
78
+ When `--template` is omitted, all supported candidates are evaluated
79
+ deterministically; multiple structural matches fail closed. Native template
80
+ boilerplate and raw Markdown are intentionally absent from successful output.
81
+
59
82
  ## Source of truth and supported semantics
60
83
 
61
- `.github/ISSUE_TEMPLATE/**` and `.github/PULL_REQUEST_TEMPLATE*` remain the structural source of truth. Inari discovers and compiles those files; it does not replace them with a proprietary body schema. Supported Issue Form nodes are `input`, `textarea`, single- and multi-select `dropdown`, `checkboxes`, and `markdown`. Browser-only or ambiguous behavior, such as uploads or unsupported textarea rendering modes, fails closed.
84
+ `.github/ISSUE_TEMPLATE/**` remains the Issue source of truth. For pull requests, Inari discovers GitHub's supported repository locations: `pull_request_template` files under the repository root, `docs/`, or `.github/`, plus `PULL_REQUEST_TEMPLATE/` directories under each location. Native PR template filenames and the `.md`/`.txt` extensions supported by Inari are matched case-insensitively. Other PR-template extension surfaces are intentionally unsupported in v1 and fail closed. Inari discovers and compiles those files; it does not replace them with a proprietary body schema. Supported Issue Form nodes are `input`, `textarea` (including native `render` code fences), single- and multi-select `dropdown`, `checkboxes`, and `markdown`. Browser-only or ambiguous behavior, such as uploads or unsupported textarea rendering modes, fails closed. Markdown nodes are retained for contract/schema explainability but never emitted into an Issue body.
62
85
 
63
86
  Native PR Markdown expresses structure but not policy. A small versioned overlay may add constraints unavailable in Markdown without changing section order or content. The supported v1 form is:
64
87
 
@@ -77,15 +100,27 @@ sections:
77
100
  requireComplete: false
78
101
  ```
79
102
 
80
- References must match native section identities (`id`/`sourceId`); stale, unknown, or ambiguous references fail closed. No overlay rule means no inferred requirement.
103
+ One repository policy file can bind several native PR templates with `templates`; every entry in a multi-template policy must identify one template by stable `id`, `path`, or unique `name`:
81
104
 
82
- Issue Form top-level `labels` are preserved as create defaults. Top-level `assignees`, `projects`, and `type`, upload fields, and textarea code-block rendering are explicitly unsupported in v1 and fail closed rather than being approximated.
105
+ ```yaml
106
+ version: 1
107
+ templates:
108
+ - template: default
109
+ sections: []
110
+ - template:
111
+ path: .github/PULL_REQUEST_TEMPLATE/release.md
112
+ sections: []
113
+ ```
114
+
115
+ Template and section bindings are deterministic. Stale, unknown, or ambiguous template/section references fail closed. `linkedIssue: true` means that the field contains a GitHub closing reference: `close`, `closes`, `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, or `resolved`, followed by `#ISSUE-NUMBER` or `OWNER/REPOSITORY#ISSUE-NUMBER`, with an optional colon and case-insensitive keyword. This validates syntax only; GitHub applies automatic linking/closure only under its own contextual rules, including the target default branch.
116
+
117
+ Issue Form top-level `title` is the exact default title used when the caller omits a title; an explicit caller title replaces it without inferred prefix concatenation. Top-level `labels` are repository-governed defaults and are always retained; caller-supplied labels are appended in order with duplicates removed. Top-level `assignees`, `projects`, and `type`, and upload fields remain unsupported and fail closed rather than being approximated.
83
118
 
84
119
  ## Scope
85
120
 
86
121
  Inari owns repository-governed GitHub mutations: it reads repository-native governance, exposes it as machine-readable contracts, validates structured input against that governance, renders the canonical artifact, and performs the corresponding `gh` operation — or rejects it with actionable feedback.
87
122
 
88
- Inari is not a general GitHub CLI wrapper. Generic read/query operations such as `pr view`, `issue view`, diff/search summarization, or token-efficient GitHub inspection are out of scope.
123
+ Inari is not a general GitHub CLI wrapper. Generic read/query operations such as `pr view`, `issue view`, diff/search summarization, or token-efficient GitHub inspection remain out of scope; `get` only reconstructs artifacts that match the repository-governed canonical contract.
89
124
 
90
125
  ## Safety and existing artifacts
91
126
 
@@ -96,7 +131,9 @@ resolve template -> compile contract -> validate semantic JSON
96
131
  -> render canonical Markdown -> construct validated-rendered artifact -> call gh
97
132
  ```
98
133
 
99
- Schema, validate, and render never call a remote mutation. Invalid, ambiguous, unparseable, or unsupported pre-flight state cannot reach the mutation adapter. Existing Issue and PR validation fetches the artifact through the typed `gh` adapter, reconstructs semantic values, and calls the same compiler-owned validator. Diagnostics distinguish valid artifacts, ordinary semantic violations, wrong-template bodies, and unparseable structure.
134
+ `prepareIssueArtifact` and `preparePullRequestArtifact` are the trusted preparation boundary for library callers. Each reparses the exact rendered body with the same compiled contract, revalidates the reconstructed values, and compares them deterministically with the validated/materialized source values before producing an opaque, frozen capability carrying the target repository/ref provenance. The public `phase: "validated-rendered"` string is informational; a caller-created or spread object is rejected by the mutation adapter, and there is no public marker helper.
135
+
136
+ Schema, validate, and render never call a remote mutation. Invalid, ambiguous, unparseable, or unsupported pre-flight state cannot reach the mutation adapter. Existing Issue and PR validation fetches the artifact through the typed `gh` adapter, reconstructs semantic values, and calls the same compiler-owned validator. Diagnostics distinguish valid artifacts, ordinary semantic violations, wrong-template bodies, and unparseable structure. Renderer/parser drift fails with the typed `ARTIFACT_ROUND_TRIP_INVALID` preparation error.
100
137
 
101
138
  The public compiler, contract, validation, rendering, and adapter boundaries are library APIs. Future Actions or App adapters can use them without invoking or scraping CLI output.
102
139
 
@@ -1,4 +1,5 @@
1
1
  import { type SemanticValidationResult, type SemanticViolation } from "./contract/validation.js";
2
+ import { type CanonicalContract } from "./contract/ir.js";
2
3
  import { type ValidatedRenderedIssueArtifact, type ValidatedRenderedPullRequestArtifact } from "./github/types.js";
3
4
  export interface ArtifactInputMetadata {
4
5
  readonly title?: string;
@@ -13,12 +14,32 @@ export interface ArtifactInputDocument {
13
14
  readonly fields: Readonly<Record<string, unknown>>;
14
15
  readonly metadata: ArtifactInputMetadata;
15
16
  }
17
+ export interface ArtifactMetadataViolation {
18
+ readonly code: "INPUT_METADATA_INVALID";
19
+ readonly path: string;
20
+ readonly message: string;
21
+ }
16
22
  export type ArtifactInputErrorCode = "INPUT_DOCUMENT_INVALID" | "INPUT_METADATA_INVALID";
17
23
  export declare class ArtifactInputError extends Error {
18
24
  readonly code: ArtifactInputErrorCode;
19
25
  readonly path: string;
20
26
  constructor(code: ArtifactInputErrorCode, message: string, path?: string);
21
27
  }
28
+ export type ArtifactPreparationErrorCode = "ARTIFACT_PROVENANCE_MISSING" | "ARTIFACT_ROUND_TRIP_INVALID";
29
+ export type ArtifactRoundTripDiagnosticCode = "ROUND_TRIP_PARSE" | "ROUND_TRIP_SEMANTIC" | "ROUND_TRIP_MISMATCH";
30
+ export interface ArtifactRoundTripDiagnostic {
31
+ readonly code: ArtifactRoundTripDiagnosticCode;
32
+ readonly path: string;
33
+ readonly message: string;
34
+ readonly expected?: unknown;
35
+ readonly actual?: unknown;
36
+ }
37
+ /** Stable failures raised before a mutation-capable artifact is created. */
38
+ export declare class ArtifactPreparationError extends Error {
39
+ readonly code: ArtifactPreparationErrorCode;
40
+ readonly diagnostics: readonly ArtifactRoundTripDiagnostic[];
41
+ constructor(code: ArtifactPreparationErrorCode, message: string, diagnostics?: readonly ArtifactRoundTripDiagnostic[]);
42
+ }
22
43
  export interface PreparedIssueArtifact {
23
44
  readonly input: ArtifactInputDocument;
24
45
  readonly validation: SemanticValidationResult;
@@ -29,8 +50,8 @@ export interface PreparedPullRequestArtifact {
29
50
  readonly validation: SemanticValidationResult;
30
51
  readonly artifact: ValidatedRenderedPullRequestArtifact;
31
52
  }
32
- export type ExistingArtifactClassification = "valid" | "semantic" | "wrong-template" | "unparseable";
33
- export type ExistingArtifactDiagnosticCode = "EXISTING_WRONG_TEMPLATE" | "EXISTING_UNPARSEABLE" | "EXISTING_EXTRA_CONTENT" | "EXISTING_UNKNOWN_CHECKLIST_ITEM";
53
+ export type ExistingArtifactClassification = "valid" | "semantic" | "wrong-template" | "unparseable" | "ambiguous";
54
+ export type ExistingArtifactDiagnosticCode = "EXISTING_WRONG_TEMPLATE" | "EXISTING_UNPARSEABLE" | "EXISTING_EXTRA_CONTENT" | "EXISTING_UNKNOWN_CHECKLIST_ITEM" | "EXISTING_AMBIGUOUS_TEMPLATE";
34
55
  export interface ExistingArtifactDiagnostic {
35
56
  readonly code: ExistingArtifactDiagnosticCode;
36
57
  readonly path: string;
@@ -75,6 +96,28 @@ export declare function parseExistingIssueArtifact(contractInput: unknown, body:
75
96
  export declare function parseExistingPullRequestArtifact(contractInput: unknown, body: string | null | undefined): ExistingArtifactParseResult;
76
97
  export declare function validateExistingIssueArtifact(contractInput: unknown, body: string | null | undefined): ExistingArtifactValidationResult;
77
98
  export declare function validateExistingPullRequestArtifact(contractInput: unknown, body: string | null | undefined): ExistingArtifactValidationResult;
99
+ export interface ExistingArtifactCandidate {
100
+ readonly contract: CanonicalContract;
101
+ readonly result: ExistingArtifactValidationResult;
102
+ }
103
+ export interface ExistingArtifactSelection {
104
+ readonly contract?: CanonicalContract;
105
+ readonly result: ExistingArtifactValidationResult;
106
+ }
107
+ export interface ExistingArtifactProjection {
108
+ readonly valid: boolean;
109
+ readonly projection: "canonical" | "unavailable";
110
+ readonly classification: ExistingArtifactClassification;
111
+ readonly fields?: Readonly<Record<string, unknown>>;
112
+ readonly diagnostics: readonly ExistingArtifactDiagnostic[];
113
+ readonly violations: readonly ExistingArtifactDiagnostic[] | readonly SemanticViolation[];
114
+ }
115
+ /** Project only validated semantic values; invalid artifacts never expose parsed fields. */
116
+ export declare function projectExistingArtifact(result: ExistingArtifactValidationResult): ExistingArtifactProjection;
117
+ /** Select a uniquely parsed governed artifact, failing closed on ambiguity. */
118
+ export declare function selectExistingArtifactCandidate(candidates: readonly ExistingArtifactCandidate[]): ExistingArtifactSelection;
119
+ /** Validate the same required string metadata enforced by mutation preparation. */
120
+ export declare function validateRequiredMetadataString(value: unknown, key: string): ArtifactMetadataViolation | undefined;
78
121
  export declare function validateExistingIssueFromAdapter(reader: ExistingIssueReader, contract: unknown, issueNumber: number): Promise<FetchedExistingArtifact>;
79
122
  export declare function validateExistingPullRequestFromAdapter(reader: ExistingPullRequestReader, contract: unknown, pullRequestNumber: number): Promise<FetchedExistingArtifact>;
80
123
  /** Escape only Markdown constructs that could change the canonical section structure. */
package/dist/artifact.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { SemanticValidationError, validateSemanticInput, } from "./contract/validation.js";
2
- import { assertCanonicalContract } from "./contract/ir.js";
3
- import { markValidatedRenderedIssueArtifact, markValidatedRenderedPullRequestArtifact, } from "./github/types.js";
2
+ import { assertCanonicalContract, } from "./contract/ir.js";
3
+ import { createValidatedRenderedIssueArtifact, createValidatedRenderedPullRequestArtifact, } from "./github/capability.js";
4
4
  export class ArtifactInputError extends Error {
5
5
  code;
6
6
  path;
@@ -11,6 +11,18 @@ export class ArtifactInputError extends Error {
11
11
  this.path = path;
12
12
  }
13
13
  }
14
+ /** Stable failures raised before a mutation-capable artifact is created. */
15
+ export class ArtifactPreparationError extends Error {
16
+ code;
17
+ diagnostics;
18
+ constructor(code, message, diagnostics = []) {
19
+ super(message);
20
+ this.name = "ArtifactPreparationError";
21
+ this.code = code;
22
+ this.diagnostics = diagnostics;
23
+ }
24
+ }
25
+ const GITHUB_NO_RESPONSE = "_No response_";
14
26
  /** Parse the documented JSON input envelope while keeping field semantics adapter-independent. */
15
27
  export function parseArtifactInputDocument(input) {
16
28
  if (!isRecord(input))
@@ -59,15 +71,19 @@ export function prepareIssueArtifact(contractInput, input) {
59
71
  assertCanonicalContract(contractInput);
60
72
  if (contractInput.artifactKind !== "issue")
61
73
  throw new ArtifactInputError("INPUT_DOCUMENT_INVALID", "An Issue contract is required.");
74
+ requireTrustedProvenance(contractInput);
62
75
  const validation = validateSemanticInput(contractInput, input.fields);
63
76
  if (!validation.valid)
64
77
  throw new SemanticValidationError(validation.violations);
65
- const title = requiredMetadataString(input.metadata.title, "title");
66
- const labels = input.metadata.labels ?? contractInput.nativeMetadata.labels;
67
- const artifact = markValidatedRenderedIssueArtifact({
78
+ const title = requiredMetadataString(input.metadata.title ?? contractInput.nativeMetadata.title, "title");
79
+ const labels = mergeIssueLabels(contractInput.nativeMetadata.labels, input.metadata.labels);
80
+ const body = renderIssueBody(contractInput, validation.values);
81
+ verifyRenderedRoundTrip(contractInput, validation.values, body, "issue");
82
+ const artifact = createValidatedRenderedIssueArtifact({
68
83
  kind: "issue",
69
84
  title,
70
- body: renderIssueBody(contractInput, validation.values),
85
+ body,
86
+ provenance: contractInput.provenance,
71
87
  ...(labels === undefined ? {} : { labels }),
72
88
  ...(input.metadata.assignees === undefined ? {} : { assignees: input.metadata.assignees }),
73
89
  });
@@ -78,16 +94,20 @@ export function preparePullRequestArtifact(contractInput, input) {
78
94
  if (contractInput.artifactKind !== "pull_request") {
79
95
  throw new ArtifactInputError("INPUT_DOCUMENT_INVALID", "A pull request contract is required.");
80
96
  }
97
+ requireTrustedProvenance(contractInput);
81
98
  const validation = validateSemanticInput(contractInput, input.fields);
82
99
  if (!validation.valid)
83
100
  throw new SemanticValidationError(validation.violations);
84
101
  const title = requiredMetadataString(input.metadata.title, "title");
85
102
  const head = requiredMetadataString(input.metadata.head, "head");
86
103
  const base = requiredMetadataString(input.metadata.base, "base");
87
- const artifact = markValidatedRenderedPullRequestArtifact({
104
+ const body = renderPullRequestBody(contractInput, validation.values);
105
+ verifyRenderedRoundTrip(contractInput, validation.values, body, "pull_request");
106
+ const artifact = createValidatedRenderedPullRequestArtifact({
88
107
  kind: "pull_request",
89
108
  title,
90
- body: renderPullRequestBody(contractInput, validation.values),
109
+ body,
110
+ provenance: contractInput.provenance,
91
111
  head,
92
112
  base,
93
113
  ...(input.metadata.draft === undefined ? {} : { draft: input.metadata.draft }),
@@ -120,6 +140,67 @@ export function validateExistingPullRequestArtifact(contractInput, body) {
120
140
  const parse = parseExistingPullRequestArtifact(contractInput, body);
121
141
  return validateParsedArtifact(contractInput, parse);
122
142
  }
143
+ /** Project only validated semantic values; invalid artifacts never expose parsed fields. */
144
+ export function projectExistingArtifact(result) {
145
+ return {
146
+ valid: result.valid,
147
+ projection: result.valid ? "canonical" : "unavailable",
148
+ classification: result.classification,
149
+ ...(result.valid ? { fields: result.parse.values } : {}),
150
+ diagnostics: result.parse.diagnostics,
151
+ violations: result.violations,
152
+ };
153
+ }
154
+ /** Select a uniquely parsed governed artifact, failing closed on ambiguity. */
155
+ export function selectExistingArtifactCandidate(candidates) {
156
+ const parsed = candidates.filter((candidate) => candidate.result.parse.parsed);
157
+ if (parsed.length === 1) {
158
+ const selected = parsed[0];
159
+ return selected;
160
+ }
161
+ if (parsed.length > 1) {
162
+ const paths = parsed.map((candidate) => candidate.contract.templateIdentity.path).sort(compareStrings);
163
+ const diagnostic = {
164
+ code: "EXISTING_AMBIGUOUS_TEMPLATE",
165
+ path: "$.template",
166
+ message: `Artifact structure matches multiple repository-native templates: ${paths.join(", ")}.`,
167
+ };
168
+ return {
169
+ result: {
170
+ valid: false,
171
+ classification: "ambiguous",
172
+ parse: { parsed: false, values: {}, diagnostics: [diagnostic] },
173
+ violations: [diagnostic],
174
+ },
175
+ };
176
+ }
177
+ const diagnostics = candidates.flatMap((candidate) => candidate.result.parse.diagnostics.map((diagnostic) => ({
178
+ ...diagnostic,
179
+ path: `${candidate.contract.templateIdentity.path}${diagnostic.path}`,
180
+ message: `[${candidate.contract.templateIdentity.path}] ${diagnostic.message}`,
181
+ })));
182
+ const classification = candidates.some((candidate) => candidate.result.parse.diagnostics.some((diagnostic) => diagnostic.code === "EXISTING_WRONG_TEMPLATE"))
183
+ ? "wrong-template"
184
+ : "unparseable";
185
+ return {
186
+ result: {
187
+ valid: false,
188
+ classification,
189
+ parse: { parsed: false, values: {}, diagnostics },
190
+ violations: diagnostics,
191
+ },
192
+ };
193
+ }
194
+ /** Validate the same required string metadata enforced by mutation preparation. */
195
+ export function validateRequiredMetadataString(value, key) {
196
+ if (typeof value === "string" && value.trim().length > 0)
197
+ return undefined;
198
+ return {
199
+ code: "INPUT_METADATA_INVALID",
200
+ path: `$.${key}`,
201
+ message: `${key} must be a non-empty string.`,
202
+ };
203
+ }
123
204
  export async function validateExistingIssueFromAdapter(reader, contract, issueNumber) {
124
205
  const issue = await reader.getIssue(issueNumber);
125
206
  return { number: issueNumber, url: issue.url, result: validateExistingIssueArtifact(contract, issue.body) };
@@ -147,14 +228,89 @@ function validateParsedArtifact(contract, parse) {
147
228
  violations: semantic.violations,
148
229
  };
149
230
  }
231
+ function requireTrustedProvenance(contract) {
232
+ if (contract.provenance === undefined) {
233
+ throw new ArtifactPreparationError("ARTIFACT_PROVENANCE_MISSING", "Mutation preparation requires a contract bound to trusted repository governance.", [
234
+ {
235
+ code: "ROUND_TRIP_PARSE",
236
+ path: "$.provenance",
237
+ message: "The compiled contract has no trusted repository/ref provenance.",
238
+ },
239
+ ]);
240
+ }
241
+ }
242
+ function verifyRenderedRoundTrip(contract, expectedValues, body, kind) {
243
+ const parsed = kind === "issue" ? parseExistingIssueArtifact(contract, body) : parseExistingPullRequestArtifact(contract, body);
244
+ if (!parsed.parsed) {
245
+ throw new ArtifactPreparationError("ARTIFACT_ROUND_TRIP_INVALID", `Rendered ${kind} artifact did not reparse under the compiled contract.`, parsed.diagnostics.map((diagnostic) => ({
246
+ code: "ROUND_TRIP_PARSE",
247
+ path: diagnostic.path,
248
+ message: diagnostic.message,
249
+ })));
250
+ }
251
+ const reconstructed = validateSemanticInput(contract, parsed.values);
252
+ if (!reconstructed.valid) {
253
+ throw new ArtifactPreparationError("ARTIFACT_ROUND_TRIP_INVALID", `Rendered ${kind} artifact failed semantic validation after reparsing.`, reconstructed.violations.map((violation) => ({
254
+ code: "ROUND_TRIP_SEMANTIC",
255
+ path: violation.path,
256
+ message: violation.message,
257
+ })));
258
+ }
259
+ const mismatches = compareMaterializedValues(expectedValues, reconstructed.values);
260
+ if (mismatches.length > 0) {
261
+ throw new ArtifactPreparationError("ARTIFACT_ROUND_TRIP_INVALID", `Rendered ${kind} artifact did not preserve its validated semantic values.`, mismatches);
262
+ }
263
+ }
264
+ function compareMaterializedValues(expected, actual) {
265
+ const keys = [...new Set([...Object.keys(expected), ...Object.keys(actual)])].sort(compareStrings);
266
+ const diagnostics = [];
267
+ for (const key of keys) {
268
+ const expectedPresent = Object.prototype.hasOwnProperty.call(expected, key);
269
+ const actualPresent = Object.prototype.hasOwnProperty.call(actual, key);
270
+ const path = `$.${key}`;
271
+ if (!expectedPresent || !actualPresent) {
272
+ diagnostics.push({
273
+ code: "ROUND_TRIP_MISMATCH",
274
+ path,
275
+ message: "Rendered artifact changed whether a semantic field was materialized.",
276
+ ...(expectedPresent ? { expected: expected[key] } : {}),
277
+ ...(actualPresent ? { actual: actual[key] } : {}),
278
+ });
279
+ continue;
280
+ }
281
+ if (stableValue(expected[key]) !== stableValue(actual[key])) {
282
+ diagnostics.push({
283
+ code: "ROUND_TRIP_MISMATCH",
284
+ path,
285
+ message: "Rendered artifact changed a materialized semantic value.",
286
+ expected: expected[key],
287
+ actual: actual[key],
288
+ });
289
+ }
290
+ }
291
+ return diagnostics;
292
+ }
293
+ function stableValue(value) {
294
+ if (value === undefined)
295
+ return "undefined";
296
+ if (value === null || typeof value !== "object")
297
+ return JSON.stringify(value) ?? String(value);
298
+ if (Array.isArray(value))
299
+ return `[${value.map((entry) => stableValue(entry)).join(",")}]`;
300
+ const record = value;
301
+ return `{${Object.keys(record)
302
+ .sort(compareStrings)
303
+ .map((key) => `${JSON.stringify(key)}:${stableValue(record[key])}`)
304
+ .join(",")}}`;
305
+ }
150
306
  function renderIssueBody(contract, values) {
151
307
  const blocks = [];
152
308
  for (let sectionIndex = 0; sectionIndex < contract.sections.length; sectionIndex += 1) {
153
309
  const section = contract.sections[sectionIndex];
154
310
  if (section.kind === "documentation") {
155
- const content = trimBlankLines(section.content ?? "");
156
- if (content !== undefined)
157
- blocks.push(content);
311
+ // GitHub renders Issue Form markdown in the form only; it is not part of
312
+ // the submitted Issue body. The content remains in the contract for
313
+ // schema/explain and native-source traceability.
158
314
  continue;
159
315
  }
160
316
  const title = section.title ?? section.fields[0]?.label;
@@ -197,13 +353,24 @@ function renderDocumentation(section, content) {
197
353
  }
198
354
  function renderFieldValue(field, value, kind) {
199
355
  if (field.type === "string" || field.type === "enum") {
200
- if (typeof value === "string")
201
- return escapeMarkdownValue(value);
202
- return kind === "pull_request" ? (field.nativeMetadata.placeholder ?? "") : "";
356
+ if (typeof value === "string" && (kind !== "issue" || value.trim().length > 0)) {
357
+ return field.nativeMetadata.render === undefined
358
+ ? escapeMarkdownValue(value)
359
+ : renderCodeBlock(value, field.nativeMetadata.render);
360
+ }
361
+ if (kind === "pull_request")
362
+ return field.nativeMetadata.placeholder ?? "";
363
+ return field.nativeMetadata.render === undefined
364
+ ? GITHUB_NO_RESPONSE
365
+ : renderCodeBlock("", field.nativeMetadata.render);
203
366
  }
204
367
  if (field.type === "array") {
205
368
  if (!Array.isArray(value))
206
- return "";
369
+ return kind === "issue" ? GITHUB_NO_RESPONSE : "";
370
+ if (value.length === 0)
371
+ return kind === "issue" ? GITHUB_NO_RESPONSE : "";
372
+ if (field.nativeMetadata.multiple === true)
373
+ return value.map((entry) => escapeMarkdownValue(String(entry))).join(", ");
207
374
  return value.map((entry) => `- ${escapeMarkdownValue(String(entry))}`).join("\n");
208
375
  }
209
376
  const selected = new Set(Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : []);
@@ -222,7 +389,9 @@ function parseRenderedBody(contract, body, issueHeadingLevel, stripComments) {
222
389
  while (lines[cursor] !== undefined && lines[cursor]?.trim().length === 0)
223
390
  cursor += 1;
224
391
  if (section.kind === "documentation") {
225
- const expected = trimBlankLines(section.content ?? "");
392
+ if (issueHeadingLevel !== undefined)
393
+ continue;
394
+ const expected = trimBlankLines(stripComments ? removeHtmlComments(section.content ?? "") : (section.content ?? ""));
226
395
  if (expected !== undefined) {
227
396
  const expectedLines = expected.split("\n");
228
397
  if (!sameLines(lines.slice(cursor, cursor + expectedLines.length), expectedLines)) {
@@ -255,11 +424,29 @@ function parseRenderedBody(contract, body, issueHeadingLevel, stripComments) {
255
424
  }
256
425
  cursor += 1;
257
426
  const contentStart = cursor;
258
- while (cursor < lines.length && !isHeading(lines[cursor] ?? ""))
427
+ const nextIssueHeading = issueHeadingLevel === undefined ? undefined : findNextIssueHeading(contract, sectionIndex);
428
+ let openFence;
429
+ while (cursor < lines.length) {
430
+ const line = lines[cursor] ?? "";
431
+ const fenceMatch = /^(`{3,})/u.exec(line);
432
+ if (openFence === undefined) {
433
+ if (nextIssueHeading === undefined
434
+ ? issueHeadingLevel === undefined && isHeading(line)
435
+ : line.trim() === nextIssueHeading)
436
+ break;
437
+ if (fenceMatch !== null)
438
+ openFence = fenceMatch[1];
439
+ }
440
+ else if (fenceMatch !== null && fenceMatch[1] === openFence) {
441
+ openFence = undefined;
442
+ }
259
443
  cursor += 1;
444
+ }
260
445
  let fieldEnd = cursor;
261
446
  const nextSection = contract.sections[sectionIndex + 1];
262
- const nextDocumentation = nextSection?.kind === "documentation" ? trimBlankLines(nextSection.content ?? "") : undefined;
447
+ const nextDocumentation = issueHeadingLevel === undefined && nextSection?.kind === "documentation"
448
+ ? trimBlankLines(nextSection.content ?? "")
449
+ : undefined;
263
450
  if (nextDocumentation !== undefined) {
264
451
  const documentationLines = nextDocumentation.split("\n");
265
452
  const rawCandidate = lines.slice(contentStart, fieldEnd);
@@ -296,8 +483,15 @@ function parseRenderedBody(contract, body, issueHeadingLevel, stripComments) {
296
483
  function parseFieldLines(field, lines, path, stripComments) {
297
484
  const diagnostics = [];
298
485
  const filtered = stripComments ? lines.filter((line) => line.trim().length > 0) : lines;
486
+ if (filtered.length === 1 && filtered[0]?.trim() === GITHUB_NO_RESPONSE) {
487
+ // GitHub uses the same marker for an empty optional selection. Preserve
488
+ // the materialized empty array so prepared artifacts remain reversible.
489
+ return { value: field.type === "array" ? [] : undefined, diagnostics };
490
+ }
299
491
  if (field.type === "string" || field.type === "enum") {
300
- const value = trimBlankLines(unescapeMarkdownValue(filtered.join("\n")));
492
+ const value = field.nativeMetadata.render === undefined
493
+ ? trimBlankLines(unescapeMarkdownValue(filtered.join("\n")))
494
+ : parseRenderedCodeBlock(filtered, field.nativeMetadata.render, path, diagnostics);
301
495
  const placeholder = stripComments
302
496
  ? removeHtmlComments(field.nativeMetadata.placeholder ?? "")
303
497
  : (field.nativeMetadata.placeholder ?? "");
@@ -306,6 +500,18 @@ function parseFieldLines(field, lines, path, stripComments) {
306
500
  return { value, diagnostics };
307
501
  }
308
502
  if (field.type === "array") {
503
+ if (field.nativeMetadata.multiple === true) {
504
+ const value = trimBlankLines(unescapeMarkdownValue(filtered.join("\n")));
505
+ if (value === undefined)
506
+ return { value: undefined, diagnostics };
507
+ const values = value
508
+ .split(",")
509
+ .map((entry) => entry.trim())
510
+ .filter((entry) => entry.length > 0);
511
+ if (values.length === 0)
512
+ return { value: undefined, diagnostics };
513
+ return { value: values, diagnostics };
514
+ }
309
515
  const values = filtered
310
516
  .map((line) => {
311
517
  const value = /^[-+*][ \t]+(.+)$/u.exec(line)?.[1]?.trim();
@@ -359,6 +565,55 @@ function removeRenderedPlaceholder(lines, placeholder) {
359
565
  ? lines.slice(placeholderLines.length)
360
566
  : lines;
361
567
  }
568
+ function findNextIssueHeading(contract, sectionIndex) {
569
+ for (let index = sectionIndex + 1; index < contract.sections.length; index += 1) {
570
+ const section = contract.sections[index];
571
+ if (section.kind !== "input")
572
+ continue;
573
+ const title = section.title ?? section.fields[0]?.label;
574
+ if (title !== undefined)
575
+ return `### ${escapeHeading(title)}`;
576
+ }
577
+ return undefined;
578
+ }
579
+ function parseRenderedCodeBlock(lines, language, path, diagnostics) {
580
+ const opening = /^(`{3,})(.*)$/u.exec(lines[0] ?? "");
581
+ if (opening === null || opening[2] !== language || lines.length < 2) {
582
+ diagnostics.push({
583
+ code: "EXISTING_UNPARSEABLE",
584
+ path,
585
+ message: `Rendered textarea values must use a fenced ${language} code block.`,
586
+ });
587
+ return undefined;
588
+ }
589
+ const fence = opening[1];
590
+ if (lines.at(-1) !== fence) {
591
+ diagnostics.push({
592
+ code: "EXISTING_UNPARSEABLE",
593
+ path,
594
+ message: "Rendered textarea code blocks must have a matching closing fence.",
595
+ });
596
+ return undefined;
597
+ }
598
+ const value = lines.slice(1, -1).join("\n");
599
+ return value.length === 0 ? undefined : value;
600
+ }
601
+ function renderCodeBlock(value, language) {
602
+ const normalized = normalizeSource(value);
603
+ const longestFence = Math.max(0, ...Array.from(normalized.matchAll(/`+/gu), (match) => match[0]?.length ?? 0));
604
+ const fence = "`".repeat(Math.max(3, longestFence + 1));
605
+ return `${fence}${language}\n${normalized}\n${fence}`;
606
+ }
607
+ function mergeIssueLabels(nativeLabels, callerLabels) {
608
+ if (nativeLabels === undefined && callerLabels === undefined)
609
+ return undefined;
610
+ const labels = [];
611
+ for (const label of [...(nativeLabels ?? []), ...(callerLabels ?? [])]) {
612
+ if (!labels.includes(label))
613
+ labels.push(label);
614
+ }
615
+ return labels;
616
+ }
362
617
  function parseMetadata(input) {
363
618
  const metadata = {};
364
619
  if (input.title !== undefined)
@@ -381,8 +636,9 @@ function parseMetadata(input) {
381
636
  return metadata;
382
637
  }
383
638
  function requiredMetadataString(value, key) {
384
- if (typeof value !== "string" || value.trim().length === 0)
385
- throw new ArtifactInputError("INPUT_METADATA_INVALID", `${key} must be a non-empty string.`, `$.${key}`);
639
+ const violation = validateRequiredMetadataString(value, key);
640
+ if (violation !== undefined)
641
+ throw new ArtifactInputError(violation.code, violation.message, violation.path);
386
642
  return value;
387
643
  }
388
644
  function stringArray(value, key) {
@@ -469,4 +725,7 @@ function sameLines(actual, expected) {
469
725
  function isRecord(value) {
470
726
  return typeof value === "object" && value !== null && !Array.isArray(value);
471
727
  }
728
+ function compareStrings(left, right) {
729
+ return left < right ? -1 : left > right ? 1 : 0;
730
+ }
472
731
  //# sourceMappingURL=artifact.js.map