gh-inari 0.1.0 → 0.3.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.
- package/README.md +42 -5
- package/dist/artifact.d.ts +45 -2
- package/dist/artifact.js +280 -21
- package/dist/artifact.js.map +1 -1
- package/dist/cli.js +133 -69
- package/dist/cli.js.map +1 -1
- package/dist/contract/constraints.d.ts +37 -0
- package/dist/contract/constraints.js +87 -0
- package/dist/contract/constraints.js.map +1 -0
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.js +1 -0
- package/dist/contract/index.js.map +1 -1
- package/dist/contract/ir.d.ts +42 -4
- package/dist/contract/ir.js +100 -7
- package/dist/contract/ir.js.map +1 -1
- package/dist/contract/issue-form.js +9 -6
- package/dist/contract/issue-form.js.map +1 -1
- package/dist/contract/schema.d.ts +2 -1
- package/dist/contract/schema.js +22 -51
- package/dist/contract/schema.js.map +1 -1
- package/dist/contract/validation.js +28 -36
- package/dist/contract/validation.js.map +1 -1
- package/dist/github/adapter.d.ts +17 -2
- package/dist/github/adapter.js +198 -4
- package/dist/github/adapter.js.map +1 -1
- package/dist/github/capability.d.ts +6 -0
- package/dist/github/capability.js +57 -0
- package/dist/github/capability.js.map +1 -0
- package/dist/github/errors.d.ts +21 -2
- package/dist/github/errors.js +24 -0
- package/dist/github/errors.js.map +1 -1
- package/dist/github/transport.d.ts +24 -0
- package/dist/github/transport.js +122 -17
- package/dist/github/transport.js.map +1 -1
- package/dist/github/types.d.ts +21 -12
- package/dist/github/types.js +0 -14
- package/dist/github/types.js.map +1 -1
- package/dist/governance.d.ts +74 -0
- package/dist/governance.js +314 -0
- package/dist/governance.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/pr-policy.d.ts +13 -3
- package/dist/pr-policy.js +112 -27
- package/dist/pr-policy.js.map +1 -1
- package/dist/template-discovery.d.ts +13 -1
- package/dist/template-discovery.js +171 -43
- package/dist/template-discovery.js.map +1 -1
- package/gh-inari +102 -4
- 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/**`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
package/dist/artifact.d.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
67
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
385
|
-
|
|
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
|