@sleepy_panda_srl/portulan 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.
- package/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +87 -0
- package/cli/README.md +337 -0
- package/cli/compile.mjs +2209 -0
- package/cli/control-chars.mjs +570 -0
- package/cli/discover.mjs +985 -0
- package/cli/doctor.mjs +2803 -0
- package/cli/feedback.mjs +970 -0
- package/cli/gate.mjs +271 -0
- package/cli/index.mjs +1390 -0
- package/cli/init.mjs +1729 -0
- package/cli/librarian.mjs +1384 -0
- package/cli/manifest.mjs +59 -0
- package/cli/new.mjs +803 -0
- package/cli/pack-version.mjs +543 -0
- package/cli/plugin-lint.mjs +1566 -0
- package/cli/portulan.mjs +266 -0
- package/cli/recipe-set.mjs +425 -0
- package/cli/rule-carriers.mjs +489 -0
- package/cli/skills-set.mjs +715 -0
- package/cli/stop-gate.mjs +434 -0
- package/cli/upgrade.mjs +897 -0
- package/cli/vendor.mjs +1504 -0
- package/core/README.md +46 -0
- package/core/engine.md +47 -0
- package/core/operating/autonomy.md +100 -0
- package/core/operating/evolution.md +84 -0
- package/core/operating/loop.md +83 -0
- package/core/operating/memory.md +161 -0
- package/core/operating/safety.md +42 -0
- package/core/operating/verification.md +76 -0
- package/core/personas/README.md +40 -0
- package/core/personas/implementer.md +51 -0
- package/core/personas/librarian.md +73 -0
- package/core/personas/reviewer.md +52 -0
- package/core/skills/README.md +52 -0
- package/core/skills/clarify/SKILL.md +42 -0
- package/core/skills/codify/SKILL.md +65 -0
- package/core/skills/consolidate/SKILL.md +121 -0
- package/core/templates/gate-policy.md +76 -0
- package/core/templates/handoff.md +29 -0
- package/core/templates/memory-entry.md +44 -0
- package/core/templates/pack.md +94 -0
- package/core/templates/persona.md +69 -0
- package/core/templates/proposal.md +27 -0
- package/core/templates/repo-card.md +28 -0
- package/core/templates/skill.md +57 -0
- package/core/templates/task.md +26 -0
- package/core/templates/workspace.md +81 -0
- package/package.json +41 -0
- package/packs/.claude-plugin/plugin.json +22 -0
- package/packs/README.md +61 -0
- package/packs/rituals/README.md +13 -0
- package/packs/rituals/checkpoints/README.md +105 -0
- package/packs/rituals/checkpoints/pack.json +36 -0
- package/packs/rituals/checkpoints/personas/supervisor.md +64 -0
- package/packs/rituals/checkpoints/skills/milestone-close/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/session-open/SKILL.md +57 -0
- package/packs/stacks/README.md +8 -0
- package/packs/tools/README.md +17 -0
- package/packs/tools/github/README.md +65 -0
- package/packs/tools/github/pack.json +29 -0
- package/packs/tools/github/verify/README.md +51 -0
- package/packs/tools/github/verify/actions-pinned.sh +132 -0
- package/spec/README.md +399 -0
- package/spec/migrations/0001-repository-declares-its-tree.mjs +100 -0
- package/spec/migrations/0002-bundle-fallback-path.mjs +179 -0
- package/spec/migrations/README.md +95 -0
- package/spec/pack.schema.json +201 -0
- package/spec/slots.md +796 -0
- package/spec/workspace.schema.json +438 -0
package/cli/feedback.mjs
ADDED
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `portulan feedback` — file an issue from a report you previewed, seam-scanned before it leaves the
|
|
3
|
+
// machine, under the Gated tier. Milestone 7's inbound half; proposal
|
|
4
|
+
// `../.portulan/proposals/0014-a-feedback-pipe-points-out-of-the-seam.md` is the design.
|
|
5
|
+
//
|
|
6
|
+
// ## The sentence the whole file follows from
|
|
7
|
+
//
|
|
8
|
+
// **A feedback sender is a pipe from a private workspace into a record it does not control.** An issue
|
|
9
|
+
// tracker's history is permanent and its visibility is the *repository's* to change — this one was
|
|
10
|
+
// public from 2026-07-27 to 2026-08-03, private after that, and is public again, which is exactly why
|
|
11
|
+
// "it is public" is the wrong thing to reason from in either direction: what a sender may safely
|
|
12
|
+
// include cannot depend on a setting somebody else can flip, and this one has been flipped three
|
|
13
|
+
// times. The workspace it sends *from* may be a company's — repo names, paths,
|
|
14
|
+
// product identifiers, gate maps, memory. `docs/plan.md` → Protocol → The seam exists because this
|
|
15
|
+
// project treats that leakage as unacceptable in the other direction, and this tool points at the same
|
|
16
|
+
// wall pointing out.
|
|
17
|
+
//
|
|
18
|
+
// Two consequences, and they are the two things that would be hard to add later:
|
|
19
|
+
//
|
|
20
|
+
// 1. **The payload is assembled from a closed list**, never filtered after the fact. Nothing here reads
|
|
21
|
+
// the workspace's name, its repo cards, its gate map, its memory, a git remote, a branch, or any
|
|
22
|
+
// path. The one thing it reads out of the workspace is `portulan.spec`, a version number. A filter
|
|
23
|
+
// is a list of what to remove and is wrong the day something new arrives; a closed list is a list of
|
|
24
|
+
// what to include and is wrong only when somebody widens it on purpose.
|
|
25
|
+
// 2. **This product never carries seam terms.** They are client-confidential by definition. The tool is
|
|
26
|
+
// *told where a list is* and reads it; it ships none, and a term list has no place in this
|
|
27
|
+
// repository.
|
|
28
|
+
//
|
|
29
|
+
// ## Gated means per action, and the refusal lives in the tool
|
|
30
|
+
//
|
|
31
|
+
// `send` without `--approve` prints the payload and exits 2. The approval cannot be inherited from an
|
|
32
|
+
// earlier `draft` or `preview`, because `core/operating/autonomy.md`'s Gated tier is per action — and a
|
|
33
|
+
// queue that flushes itself when the network returns is a silent send with extra steps, which is why
|
|
34
|
+
// nothing here auto-sends, retries in the background, or treats an unsent draft as anything but a file.
|
|
35
|
+
//
|
|
36
|
+
// The refusal is **in the tool** rather than in a host's permission table on purpose. A `Bash(...)`
|
|
37
|
+
// matcher in `.portulan/gates.json` would cover one spelling of at least three (`portulan feedback
|
|
38
|
+
// send`, `npx portulan feedback send`, `node cli/feedback.mjs send`) and would suggest coverage it does
|
|
39
|
+
// not have. A refusal the tool itself makes holds on every host, including the hosts with no hook
|
|
40
|
+
// system at all — which is the platform-floor direction this project prefers everywhere else.
|
|
41
|
+
//
|
|
42
|
+
// ## Exit codes
|
|
43
|
+
//
|
|
44
|
+
// `0` the verb succeeded · `1` **a verdict was rendered and it was no** — today that is exactly one
|
|
45
|
+
// thing, a seam hit · `2` could not run: a report that is not ready, an approval that was not given, a
|
|
46
|
+
// term list that was named and could not be read, a missing `gh`. The split matters at the one place it
|
|
47
|
+
// is easy to get wrong: an unreadable term list is **not** a clean scan, and a report missing a required
|
|
48
|
+
// answer is not a judgement about anybody's workspace.
|
|
49
|
+
//
|
|
50
|
+
// ## What is deliberately not here
|
|
51
|
+
//
|
|
52
|
+
// No OAuth device flow — the user's existing `gh` login and nothing else. No relay: proposal `0014`'s
|
|
53
|
+
// Q5(b) is a hosted service and `docs/vision.md` permits exactly one by name, so it is constitutional
|
|
54
|
+
// and unbuilt. No telemetry, no crash reporter, no attachments, no screenshots, no log bundles — every
|
|
55
|
+
// one is an unbounded channel through the wall this file is about. And **no labels**: `0014` rules that
|
|
56
|
+
// the repository owns its own labels, so the form's title prefix is what marks the kind.
|
|
57
|
+
|
|
58
|
+
import crypto from "node:crypto";
|
|
59
|
+
import fs from "node:fs";
|
|
60
|
+
import os from "node:os";
|
|
61
|
+
import path from "node:path";
|
|
62
|
+
import { spawnSync } from "node:child_process";
|
|
63
|
+
import { pathToFileURL } from "node:url";
|
|
64
|
+
|
|
65
|
+
// `manifest.mjs` and not `portulan.mjs`: importing the entry point from a subcommand it dispatches to
|
|
66
|
+
// is a cycle, and it hung `portulan feedback --help` at exit 13. The header there is the record.
|
|
67
|
+
import { VERSION, REPOSITORY } from "./manifest.mjs";
|
|
68
|
+
|
|
69
|
+
export class FeedbackError extends Error {}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The window a second send has to clear. Proposal `0014` asks for "a short cooldown [to catch] a loop"
|
|
73
|
+
* beside the re-send no-op: the no-op stops the same report going twice, and this stops a script
|
|
74
|
+
* filing twenty different ones. Short enough that a person filing two real reports in a sitting waits
|
|
75
|
+
* once and understands why.
|
|
76
|
+
*/
|
|
77
|
+
export const COOLDOWN_SECONDS = 60;
|
|
78
|
+
|
|
79
|
+
// ===========================================================================================
|
|
80
|
+
// The forms
|
|
81
|
+
// ===========================================================================================
|
|
82
|
+
//
|
|
83
|
+
// THE ONE CARRIER, and it is a carrier because the alternative does not exist. The forms live at
|
|
84
|
+
// `.github/ISSUE_TEMPLATE/*.yml` and `package.json`'s `files` does not ship them, so a published
|
|
85
|
+
// package cannot read them; parsing YAML would need a dependency this CLI is ruled not to have, and
|
|
86
|
+
// generating this map would need a build step it is ruled not to have either. So the map is written
|
|
87
|
+
// here and `feedback.live.test.mjs` reads the real forms and fails when the two disagree — `0020`'s
|
|
88
|
+
// repair where a single carrier is impossible: one carrier plus a rail on the pair.
|
|
89
|
+
//
|
|
90
|
+
// `filled` marks a section this tool answers rather than the user: `version` is the Portulan version,
|
|
91
|
+
// `environment` is the structured block. A fact claimed by a `filled` section is left out of the
|
|
92
|
+
// environment block, so a payload never states the same thing twice.
|
|
93
|
+
export const FORMS = [
|
|
94
|
+
{
|
|
95
|
+
kind: "bug",
|
|
96
|
+
file: "bug.yml",
|
|
97
|
+
titlePrefix: "[bug] ",
|
|
98
|
+
acknowledgementsLabel: "Before you file",
|
|
99
|
+
sections: [
|
|
100
|
+
{ id: "what", label: "What happened", required: true },
|
|
101
|
+
{ id: "expected", label: "What you expected, and where Portulan says so", required: true },
|
|
102
|
+
{ id: "reproduce", label: "How to reproduce", required: false, render: "shell" },
|
|
103
|
+
{ id: "version", label: "Version or commit", required: false, filled: "version" },
|
|
104
|
+
{ id: "environment", label: "Environment", required: false, filled: "environment" },
|
|
105
|
+
],
|
|
106
|
+
acknowledgements: [
|
|
107
|
+
{ text: "I searched existing issues and this is not already reported.", required: true },
|
|
108
|
+
{
|
|
109
|
+
text:
|
|
110
|
+
"I understand that Portulan does not accept outside pull requests, and that this report is " +
|
|
111
|
+
"the contribution — not a lesser path to one.",
|
|
112
|
+
required: true,
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
kind: "improvement",
|
|
118
|
+
file: "improvement.yml",
|
|
119
|
+
titlePrefix: "[improvement] ",
|
|
120
|
+
acknowledgementsLabel: "Before you file",
|
|
121
|
+
sections: [
|
|
122
|
+
{ id: "problem", label: "The problem", required: true },
|
|
123
|
+
{ id: "proposal", label: "What you propose", required: true },
|
|
124
|
+
{ id: "enforcement", label: "How it would earn its place", required: true },
|
|
125
|
+
{ id: "cost", label: "What it costs, and what it would break", required: false },
|
|
126
|
+
{
|
|
127
|
+
id: "area",
|
|
128
|
+
label: "Which part of Portulan",
|
|
129
|
+
required: true,
|
|
130
|
+
options: [
|
|
131
|
+
"doctrine — core/: the loop, autonomy, verification, memory, evolution, safety",
|
|
132
|
+
"mechanism — cli/, spec/, the plugin: the code that checks and enforces",
|
|
133
|
+
"workspace — .portulan/: the policy layer and the gate map",
|
|
134
|
+
"packaging — the plugin, the marketplace, install and boot",
|
|
135
|
+
"documentation — README, the plan, the examples",
|
|
136
|
+
"not sure",
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
{ id: "environment", label: "Environment", required: false, filled: "environment" },
|
|
140
|
+
],
|
|
141
|
+
acknowledgements: [
|
|
142
|
+
{ text: "I searched existing issues and proposals, and this is not already open.", required: true },
|
|
143
|
+
{ text: "I have read `docs/vision.md` and this does not contradict a stated non-goal.", required: false },
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
kind: "feedback",
|
|
148
|
+
file: "feedback.yml",
|
|
149
|
+
titlePrefix: "[feedback] ",
|
|
150
|
+
acknowledgementsLabel: "Before you file",
|
|
151
|
+
sections: [
|
|
152
|
+
{
|
|
153
|
+
id: "kind",
|
|
154
|
+
label: "What kind of feedback",
|
|
155
|
+
required: true,
|
|
156
|
+
options: [
|
|
157
|
+
"Something was confusing or hard to follow",
|
|
158
|
+
"Something did not survive contact with my repository",
|
|
159
|
+
"I expected to find something and did not",
|
|
160
|
+
"A disagreement with a decision or a non-goal",
|
|
161
|
+
"Something worked well and is worth keeping",
|
|
162
|
+
"Other",
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
{ id: "feedback", label: "The feedback", required: true },
|
|
166
|
+
{ id: "context", label: "What you were trying to do", required: false },
|
|
167
|
+
{ id: "environment", label: "Environment", required: false, filled: "environment" },
|
|
168
|
+
],
|
|
169
|
+
acknowledgements: [
|
|
170
|
+
{
|
|
171
|
+
text:
|
|
172
|
+
"I understand this issue is a permanent record that everyone who can read this repository " +
|
|
173
|
+
"sees, and I have not included anything confidential — my own, my employer's, or anyone " +
|
|
174
|
+
"else's.",
|
|
175
|
+
required: true,
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
export function form(kind) {
|
|
182
|
+
return FORMS.find((entry) => entry.kind === kind) ?? null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The repository issues are filed into — `manifest.mjs` derives it from the `bugs.url` we publish. */
|
|
186
|
+
export function defaultRepository() {
|
|
187
|
+
return REPOSITORY;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ===========================================================================================
|
|
191
|
+
// The report file
|
|
192
|
+
// ===========================================================================================
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* A filename slug, and it returns "" rather than inventing one. A title of punctuation has no name in
|
|
196
|
+
* it, and a report called `2026-08-10-.md` is a file nobody can find again.
|
|
197
|
+
*/
|
|
198
|
+
export function slug(title) {
|
|
199
|
+
return String(title)
|
|
200
|
+
.toLowerCase()
|
|
201
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
202
|
+
.replace(/^-+|-+$/g, "");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The scaffolded report — a file the user edits, which is the whole point of the file-first design. */
|
|
206
|
+
export function scaffold(kind, { title, created, failedRecipe, failedExit } = {}) {
|
|
207
|
+
const declared = form(kind);
|
|
208
|
+
if (!declared) throw new FeedbackError(`\`${kind}\` is not a form. The three are: ${FORMS.map((f) => f.kind).join(" · ")}`);
|
|
209
|
+
|
|
210
|
+
const front = ["---", `kind: ${kind}`, `title: ${title}`, `created: ${created}`];
|
|
211
|
+
if (failedRecipe !== undefined) front.push(`failed-recipe: ${failedRecipe}`);
|
|
212
|
+
if (failedExit !== undefined) front.push(`failed-exit: ${failedExit}`);
|
|
213
|
+
front.push("---");
|
|
214
|
+
|
|
215
|
+
const parts = [
|
|
216
|
+
front.join("\n"),
|
|
217
|
+
"<!-- Fill the sections below, tick the acknowledgements, then:\n" +
|
|
218
|
+
" portulan feedback preview <this file>\n" +
|
|
219
|
+
" portulan feedback send <this file> --approve\n" +
|
|
220
|
+
" Everything inside an HTML comment is stripped before anything is sent. -->",
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
for (const section of declared.sections) {
|
|
224
|
+
if (section.filled) continue; // this tool answers it; a user editing it would be edited over
|
|
225
|
+
parts.push(`### ${section.label}`);
|
|
226
|
+
const guidance = section.options
|
|
227
|
+
? "<!-- Keep exactly ONE of the lines below and delete the rest. -->\n" + section.options.join("\n")
|
|
228
|
+
: `<!-- ${section.required ? "Required." : "Optional — leave it empty and it files as _No response_."} -->`;
|
|
229
|
+
parts.push(guidance);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
parts.push(`### ${declared.acknowledgementsLabel}`);
|
|
233
|
+
parts.push(declared.acknowledgements.map((ack) => `- [ ] ${ack.text}`).join("\n"));
|
|
234
|
+
|
|
235
|
+
return `${parts.join("\n\n")}\n`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Read a report back. Frontmatter through `plugin-lint.mjs`'s reader would drag a 74 KB module in for
|
|
240
|
+
* four `key: value` lines, so this reads the block directly — and the block it reads is the block this
|
|
241
|
+
* file wrote, which is the case that makes a local reader honest rather than lazy.
|
|
242
|
+
*
|
|
243
|
+
* @returns {{fields: Record<string,string>, sections: Array<{label: string, text: string}>,
|
|
244
|
+
* acknowledgements: Array<{text: string, ticked: boolean}>}}
|
|
245
|
+
*/
|
|
246
|
+
export function parseReport(text) {
|
|
247
|
+
const lines = text.split(/\r?\n/);
|
|
248
|
+
if (lines[0]?.trim() !== "---") throw new FeedbackError("the report has no frontmatter block");
|
|
249
|
+
const close = lines.indexOf("---", 1);
|
|
250
|
+
if (close === -1) throw new FeedbackError("the report's frontmatter block is never closed");
|
|
251
|
+
|
|
252
|
+
const fields = {};
|
|
253
|
+
for (const line of lines.slice(1, close)) {
|
|
254
|
+
const match = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line);
|
|
255
|
+
if (match) fields[match[1]] = match[2].trim();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const declared = form(fields.kind);
|
|
259
|
+
if (!declared) {
|
|
260
|
+
throw new FeedbackError(
|
|
261
|
+
`the report's \`kind\` is \`${fields.kind ?? "(absent)"}\`, which is not a form. The three are: ` +
|
|
262
|
+
FORMS.map((f) => f.kind).join(" · "),
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const sections = [];
|
|
267
|
+
const acknowledgements = [];
|
|
268
|
+
let current = null;
|
|
269
|
+
for (const line of lines.slice(close + 1)) {
|
|
270
|
+
const heading = /^###\s+(.*)$/.exec(line);
|
|
271
|
+
if (heading) {
|
|
272
|
+
current = { label: heading[1].trim(), lines: [] };
|
|
273
|
+
if (current.label === declared.acknowledgementsLabel) current.acks = true;
|
|
274
|
+
else sections.push(current);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (!current) continue;
|
|
278
|
+
if (current.acks) {
|
|
279
|
+
const box = /^-\s+\[([ xX])\]\s+(.*)$/.exec(line);
|
|
280
|
+
if (box) acknowledgements.push({ text: box[2].trim(), ticked: box[1] !== " " });
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
current.lines.push(line);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
for (const section of sections) {
|
|
287
|
+
section.text = stripComments(section.lines.join("\n")).trim();
|
|
288
|
+
delete section.lines;
|
|
289
|
+
}
|
|
290
|
+
return { fields, sections, acknowledgements };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** HTML comments carry this tool's guidance to the author; none of it belongs in a public issue. */
|
|
294
|
+
function stripComments(text) {
|
|
295
|
+
return text.replace(/<!--[\s\S]*?-->/g, "");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ===========================================================================================
|
|
299
|
+
// The payload
|
|
300
|
+
// ===========================================================================================
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The bytes that would be filed. `preview` and `send` both call this, which is *why* the demonstration
|
|
304
|
+
* holds: the previewed body is not compared with the sent body, it **is** the sent body. Two renderers
|
|
305
|
+
* that agree today are two carriers of one fact.
|
|
306
|
+
*
|
|
307
|
+
* `parts` is the same content split by section, so the seam scan runs over exactly what would leave the
|
|
308
|
+
* machine rather than over a reconstruction of it.
|
|
309
|
+
*
|
|
310
|
+
* @returns {{repo: string|null, title: string, body: string, parts: Array<{label: string, text: string}>}}
|
|
311
|
+
*/
|
|
312
|
+
export function payload(report, context = {}) {
|
|
313
|
+
const declared = form(report.fields.kind);
|
|
314
|
+
if (!declared) throw new FeedbackError(`\`${report.fields.kind}\` is not a form`);
|
|
315
|
+
|
|
316
|
+
const title = `${declared.titlePrefix}${report.fields.title ?? ""}`;
|
|
317
|
+
const claimed = new Set(declared.sections.filter((s) => s.filled).map((s) => s.filled));
|
|
318
|
+
const parts = [{ label: "the title", text: title }];
|
|
319
|
+
|
|
320
|
+
for (const section of declared.sections) {
|
|
321
|
+
let value;
|
|
322
|
+
if (section.filled === "version") value = VERSION;
|
|
323
|
+
else if (section.filled === "environment") value = environmentBlock(report, context, claimed);
|
|
324
|
+
else value = report.sections.find((s) => s.label === section.label)?.text ?? "";
|
|
325
|
+
|
|
326
|
+
if (value && section.render === "shell") value = `\`\`\`shell\n${value}\n\`\`\``;
|
|
327
|
+
parts.push({ label: section.label, text: value || "_No response_" });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const ticks = declared.acknowledgements
|
|
331
|
+
.map((ack) => {
|
|
332
|
+
const said = report.acknowledgements.find((a) => a.text === ack.text);
|
|
333
|
+
return `- [${said?.ticked ? "X" : " "}] ${ack.text}`;
|
|
334
|
+
})
|
|
335
|
+
.join("\n");
|
|
336
|
+
parts.push({ label: declared.acknowledgementsLabel, text: ticks });
|
|
337
|
+
|
|
338
|
+
const body = parts
|
|
339
|
+
.filter((part) => part.label !== "the title")
|
|
340
|
+
.map((part) => `### ${part.label}\n\n${part.text}`)
|
|
341
|
+
.join("\n\n");
|
|
342
|
+
|
|
343
|
+
return { repo: context.repo ?? defaultRepository(), title, body, parts };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Proposal `0014`'s second part: "Portulan version, spec version, host and version, OS and
|
|
348
|
+
* architecture. If the report was started from a failure, the failing recipe's **id and exit code**."
|
|
349
|
+
* Not the recipe's *output*, which carries paths and file contents.
|
|
350
|
+
*
|
|
351
|
+
* `claimed` holds what another field on this form already asks for, so nothing is said twice.
|
|
352
|
+
*/
|
|
353
|
+
function environmentBlock(report, context, claimed = new Set()) {
|
|
354
|
+
const facts = context.facts ?? {};
|
|
355
|
+
const lines = [];
|
|
356
|
+
if (!claimed.has("version")) lines.push(`Portulan: ${VERSION}`);
|
|
357
|
+
lines.push(`Workspace Definition: ${context.spec ?? "unknown"}`);
|
|
358
|
+
lines.push(
|
|
359
|
+
`Host: ${context.host ?? "not observable from the command line — re-run with --host to name it"}`,
|
|
360
|
+
);
|
|
361
|
+
lines.push(`Runtime: node ${facts.node ?? process.version}`);
|
|
362
|
+
// Every fact defaults to the real machine. `?? ""` on the release was a real defect and it was
|
|
363
|
+
// invisible in the suite, which injects all four: the shipped tool printed `System: darwin arm64`
|
|
364
|
+
// — a line silently one fact short of what this block says it carries. Caught by running the
|
|
365
|
+
// demonstration rather than by reading the code, which is where this class keeps being caught.
|
|
366
|
+
lines.push(`System: ${facts.platform ?? process.platform} ${facts.release ?? os.release()} ${facts.arch ?? process.arch}`);
|
|
367
|
+
if (report.fields["failed-recipe"]) {
|
|
368
|
+
lines.push(`Failing recipe: ${report.fields["failed-recipe"]}, exit ${report.fields["failed-exit"] ?? "unrecorded"}`);
|
|
369
|
+
}
|
|
370
|
+
return lines.join("\n");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ===========================================================================================
|
|
374
|
+
// The seam
|
|
375
|
+
// ===========================================================================================
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Find the term list, and be exact about which of the three locations answered — a scan whose source a
|
|
379
|
+
* reader cannot name is a scan they cannot check.
|
|
380
|
+
*
|
|
381
|
+
* **Only `ENOENT` means absent, and only at the convention path.** `existsSync` answers false for
|
|
382
|
+
* `EACCES`, which is how #166's layer at mode `0400` skipped every location it declared while reporting
|
|
383
|
+
* green. A named list that cannot be read is could-not-run, always.
|
|
384
|
+
*
|
|
385
|
+
* @returns {{source: string|null, terms: string[]|null}} `source: null` means no list is configured.
|
|
386
|
+
*/
|
|
387
|
+
export function locateTerms({ flag, env = {}, workspaceDir } = {}) {
|
|
388
|
+
if (flag) return { source: `--seam-terms ${flag}`, terms: read(flag, `--seam-terms ${flag}`) };
|
|
389
|
+
|
|
390
|
+
const named = env.PORTULAN_SEAM_TERMS;
|
|
391
|
+
if (named) return { source: `PORTULAN_SEAM_TERMS (${named})`, terms: read(named, `PORTULAN_SEAM_TERMS (${named})`) };
|
|
392
|
+
|
|
393
|
+
if (!workspaceDir) return { source: null, terms: null };
|
|
394
|
+
const at = path.join(workspaceDir, "seam-terms.txt");
|
|
395
|
+
try {
|
|
396
|
+
return { source: at, terms: parseTerms(fs.readFileSync(at, "utf8")) };
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (error.code === "ENOENT") return { source: null, terms: null };
|
|
399
|
+
throw new FeedbackError(`the term list at ${at} could not be read — ${error.code ?? error.message}`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function read(at, described) {
|
|
403
|
+
try {
|
|
404
|
+
return parseTerms(fs.readFileSync(at, "utf8"));
|
|
405
|
+
} catch (error) {
|
|
406
|
+
throw new FeedbackError(`the term list named by ${described} could not be read — ${error.code ?? error.message}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* `null` when the path is there, `"ENOENT"` when it is genuinely absent, and the errno otherwise. The
|
|
413
|
+
* one carrier of *only ENOENT means absent* for path probes in this file — `existsSync` collapses all
|
|
414
|
+
* three into `false`, which is how a tool tells a reader the wrong thing about their disk with complete
|
|
415
|
+
* confidence.
|
|
416
|
+
*
|
|
417
|
+
* `lstatSync` rather than `accessSync(R_OK)` or `statSync`, and each difference is a question being
|
|
418
|
+
* asked. Both of this file's probes ask *is something already here*, not *may I read it* — a report at
|
|
419
|
+
* mode `0000` is a file that exists and must not be written over, while an unsearchable parent is a
|
|
420
|
+
* place this tool cannot see into, and that deserves a different sentence.
|
|
421
|
+
*
|
|
422
|
+
* **And `lstat`, because `stat` follows symlinks.** A dangling symlink at the report path makes `stat`
|
|
423
|
+
* throw `ENOENT`, so the probe would answer *absent*, and the write that followed would resolve the link
|
|
424
|
+
* and **land outside the workspace** — which is how a scaffold leaves the tree it was meant to stay
|
|
425
|
+
* inside, the rule `cli/new.mjs` already refuses on. `lstat` describes the link itself, so a symlink of
|
|
426
|
+
* any kind is *something is already here*. Found by review on this pull request, as a sibling of the
|
|
427
|
+
* `existsSync` repair one round earlier: the same probe, corrected twice, because the first correction
|
|
428
|
+
* answered only the errno half of the question.
|
|
429
|
+
*/
|
|
430
|
+
function whyAbsent(at) {
|
|
431
|
+
try {
|
|
432
|
+
fs.lstatSync(at);
|
|
433
|
+
return null;
|
|
434
|
+
} catch (error) {
|
|
435
|
+
return error.code ?? "unreadable";
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Every write this tool makes, through one place, and every failure of one turned into a sentence.
|
|
441
|
+
*
|
|
442
|
+
* A bare `writeFileSync` on a read-only directory reached the top-level catch, which prints
|
|
443
|
+
* `unanticipated failure` and a stack trace. The exit code was already 2, so this is not a fail-open —
|
|
444
|
+
* it is the other half of the same discipline: **could-not-run has to say what could not run**, and a
|
|
445
|
+
* stack trace is what a tool prints when it has nothing to say. Found by review on this pull request,
|
|
446
|
+
* at three sites.
|
|
447
|
+
*/
|
|
448
|
+
function writeOrRefuse(at, contents, what) {
|
|
449
|
+
try {
|
|
450
|
+
fs.mkdirSync(path.dirname(at), { recursive: true });
|
|
451
|
+
fs.writeFileSync(at, contents);
|
|
452
|
+
} catch (error) {
|
|
453
|
+
throw new FeedbackError(`${what} could not be written to ${at} — ${error.code ?? error.message}`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function parseTerms(text) {
|
|
458
|
+
return text
|
|
459
|
+
.split(/\r?\n/)
|
|
460
|
+
.map((line) => line.trim())
|
|
461
|
+
.filter((line) => line && !line.startsWith("#"))
|
|
462
|
+
.map((line) => line.toLowerCase());
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The digest of a payload, and the reason `preview` is not optional.
|
|
467
|
+
*
|
|
468
|
+
* Row 7's demonstration is *"a send whose exact payload the user saw first"*, and for one draft of this
|
|
469
|
+
* tool that was a property of the **flow** rather than of the mechanism: `send --approve` on a report
|
|
470
|
+
* nobody had previewed filed sight-unseen, and a report edited between the preview and the send filed
|
|
471
|
+
* bytes that differed from the ones that were read. Both printed the payload in the same breath, which
|
|
472
|
+
* is not the same as having been seen — nobody is reading a terminal between two lines of one command.
|
|
473
|
+
*
|
|
474
|
+
* So `preview` stamps this digest into the report and `send` refuses unless it matches. The approval is
|
|
475
|
+
* then bound to bytes rather than to an intention, and *the user saw it first* is structural. It covers
|
|
476
|
+
* the machine facts too, since they are in the payload: preview with `--host X` and send without, and
|
|
477
|
+
* the digest differs, because the bytes do.
|
|
478
|
+
*
|
|
479
|
+
* **Only `preview` stamps.** A `send` that refused for want of approval also printed the payload, and
|
|
480
|
+
* stamping there would let a refusal manufacture the approval-eligibility it just declined to grant.
|
|
481
|
+
*/
|
|
482
|
+
export function digest(built) {
|
|
483
|
+
return crypto.createHash("sha256").update(`${built.title}\n${built.body}`, "utf8").digest("hex");
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Case-insensitive substring, over the payload's own parts. Substring rather than word-boundary
|
|
488
|
+
* because the terms that matter here are names and identifiers that turn up glued to other text —
|
|
489
|
+
* a host, a path fragment, a ticket prefix — and a scan that missed those would be the reassuring kind.
|
|
490
|
+
*
|
|
491
|
+
* The hit names the term and the section and never quotes the surrounding text: this output goes to a
|
|
492
|
+
* terminal and often to a log, and echoing the context would leak the thing the scan just caught.
|
|
493
|
+
*/
|
|
494
|
+
export function scan(parts, terms) {
|
|
495
|
+
const hits = [];
|
|
496
|
+
for (const part of parts) {
|
|
497
|
+
const haystack = String(part.text).toLowerCase();
|
|
498
|
+
for (const term of terms) {
|
|
499
|
+
if (haystack.includes(term)) hits.push({ term, section: part.label });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return hits;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ===========================================================================================
|
|
506
|
+
// The verbs
|
|
507
|
+
// ===========================================================================================
|
|
508
|
+
|
|
509
|
+
const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
510
|
+
|
|
511
|
+
export function usage() {
|
|
512
|
+
return [
|
|
513
|
+
"portulan feedback — file an issue from a report you previewed",
|
|
514
|
+
"",
|
|
515
|
+
" portulan feedback draft <kind> --title <text> [--into <workspace-dir>]",
|
|
516
|
+
" [--failed-recipe <id> --failed-exit <n>]",
|
|
517
|
+
" portulan feedback preview <report> [--seam-terms <file>] [--host <name>]",
|
|
518
|
+
" portulan feedback send <report> --approve [--seam-terms <file>] [--host <name>] [--repo <owner/name>]",
|
|
519
|
+
"",
|
|
520
|
+
` kinds: ${FORMS.map((f) => f.kind).join(" · ")}`,
|
|
521
|
+
"",
|
|
522
|
+
"The report is a file before it is a request: `draft` writes one into the workspace's",
|
|
523
|
+
"`feedback/` directory, you edit it, `preview` shows the exact bytes, and `send` files them",
|
|
524
|
+
"through your own `gh` login. Approval is per send and is never inherited — and `send` refuses",
|
|
525
|
+
"any report whose payload does not match the one `preview` last showed you, so the bytes you",
|
|
526
|
+
"approved are the bytes that leave.",
|
|
527
|
+
"",
|
|
528
|
+
"The seam term list is yours and this tool ships none. It is looked for at --seam-terms, then",
|
|
529
|
+
"$PORTULAN_SEAM_TERMS, then <workspace-dir>/seam-terms.txt. A hit refuses the send (exit 1); a",
|
|
530
|
+
"list that is named and unreadable refuses it too (exit 2); no list at all is reported in the",
|
|
531
|
+
"preview rather than passed over in silence.",
|
|
532
|
+
"",
|
|
533
|
+
"Exit codes: 0 succeeded · 1 the scan refused it · 2 could not run.",
|
|
534
|
+
].join("\n");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function parseArgs(argv) {
|
|
538
|
+
const parsed = { rest: [], flags: {} };
|
|
539
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
540
|
+
const arg = argv[i];
|
|
541
|
+
if (arg === "--help" || arg === "-h") parsed.flags.help = true;
|
|
542
|
+
else if (arg === "--approve") parsed.flags.approve = true;
|
|
543
|
+
else if (arg.startsWith("--")) {
|
|
544
|
+
const key = arg.slice(2);
|
|
545
|
+
const value = argv[i + 1];
|
|
546
|
+
if (value === undefined || value.startsWith("--")) throw new FeedbackError(`\`--${key}\` needs a value`);
|
|
547
|
+
parsed.flags[key] = value;
|
|
548
|
+
i += 1;
|
|
549
|
+
} else parsed.rest.push(arg);
|
|
550
|
+
}
|
|
551
|
+
return parsed;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function run(argv, options = {}) {
|
|
555
|
+
const say = options.say ?? ((line = "") => process.stdout.write(`${line}\n`));
|
|
556
|
+
const warn = options.warn ?? ((line = "") => process.stderr.write(`${line}\n`));
|
|
557
|
+
const now = options.now ?? (() => new Date());
|
|
558
|
+
const env = options.env ?? process.env;
|
|
559
|
+
const exec =
|
|
560
|
+
options.exec ??
|
|
561
|
+
((cmd, args, spawn = {}) => {
|
|
562
|
+
const result = spawnSync(cmd, args, { input: spawn.input, encoding: "utf8" });
|
|
563
|
+
return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "", error: result.error };
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
try {
|
|
567
|
+
const parsed = parseArgs(argv);
|
|
568
|
+
const [verb, ...rest] = parsed.rest;
|
|
569
|
+
if (parsed.flags.help) {
|
|
570
|
+
say(usage());
|
|
571
|
+
return 0;
|
|
572
|
+
}
|
|
573
|
+
if (!verb) {
|
|
574
|
+
warn("feedback: no verb. Run `portulan feedback --help`.");
|
|
575
|
+
return 2;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (verb === "draft") return draft(rest, parsed.flags, { say, warn, now });
|
|
579
|
+
if (verb === "preview" || verb === "send") {
|
|
580
|
+
return report(verb, rest, parsed.flags, { say, warn, now, env, exec, facts: options.facts });
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
warn(`feedback: \`${verb}\` is not a verb. The three are: draft · preview · send.`);
|
|
584
|
+
return 2;
|
|
585
|
+
} catch (error) {
|
|
586
|
+
warn(`feedback: ${error instanceof FeedbackError ? error.message : `unanticipated failure — ${error.stack ?? error}`}`);
|
|
587
|
+
return 2;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function draft(rest, flags, { say, warn, now }) {
|
|
592
|
+
const [kind] = rest;
|
|
593
|
+
if (!kind) {
|
|
594
|
+
warn(`feedback: draft needs a kind. The three are: ${FORMS.map((f) => f.kind).join(" · ")}`);
|
|
595
|
+
return 2;
|
|
596
|
+
}
|
|
597
|
+
if (!form(kind)) {
|
|
598
|
+
warn(`feedback: \`${kind}\` is not a form. The three are: ${FORMS.map((f) => f.kind).join(" · ")}`);
|
|
599
|
+
return 2;
|
|
600
|
+
}
|
|
601
|
+
const title = flags.title;
|
|
602
|
+
if (!title) {
|
|
603
|
+
warn("feedback: draft needs a --title. It becomes the issue's title and the report's filename.");
|
|
604
|
+
return 2;
|
|
605
|
+
}
|
|
606
|
+
// A single line, refused at the door beside the failure pair below and for the same reason. The
|
|
607
|
+
// title is written verbatim into `title: …`, and frontmatter is one key per line — so a newline or
|
|
608
|
+
// a carriage return would split it, and everything after the break would be read as another key or
|
|
609
|
+
// as body. This door already validates the two facts it is handed; the title was the neighbour it
|
|
610
|
+
// did not. Found by review on this pull request. The refusal is also the `control-chars` rule this
|
|
611
|
+
// repository already rails the tree with, applied to an argument.
|
|
612
|
+
if (/[\r\n]/.test(title)) {
|
|
613
|
+
warn("feedback: a title is one line. This one carries a line break, and frontmatter is one key per line —");
|
|
614
|
+
warn("feedback: everything after the break would be read as another key, or as the body.");
|
|
615
|
+
return 2;
|
|
616
|
+
}
|
|
617
|
+
const name = slug(title);
|
|
618
|
+
if (!name) {
|
|
619
|
+
warn(`feedback: \`${title}\` yields no slug — a title needs letters or digits, since it names the file too.`);
|
|
620
|
+
return 2;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// The failure pair is validated at the door rather than on the way out. A recipe id that is not a
|
|
624
|
+
// slug, or an exit code that is not an integer, means somebody is passing something other than the
|
|
625
|
+
// two facts `0014` allows — and this is the one place a report picks up anything it was not typed.
|
|
626
|
+
const failedRecipe = flags["failed-recipe"];
|
|
627
|
+
const failedExit = flags["failed-exit"];
|
|
628
|
+
if (failedRecipe !== undefined && !SLUG.test(failedRecipe)) {
|
|
629
|
+
warn(`feedback: \`${failedRecipe}\` is not a recipe id. Ids here are lowercase, digits and single hyphens.`);
|
|
630
|
+
return 2;
|
|
631
|
+
}
|
|
632
|
+
if (failedExit !== undefined && !/^-?\d+$/.test(failedExit)) {
|
|
633
|
+
warn(`feedback: \`${failedExit}\` is not an exit code. Only the recipe's id and its integer code travel — never its output.`);
|
|
634
|
+
return 2;
|
|
635
|
+
}
|
|
636
|
+
if ((failedRecipe === undefined) !== (failedExit === undefined)) {
|
|
637
|
+
warn("feedback: --failed-recipe and --failed-exit are one fact and travel together.");
|
|
638
|
+
return 2;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const workspaceDir = path.resolve(flags.into ?? ".portulan");
|
|
642
|
+
// ONLY `ENOENT` MEANS ABSENT, here as at the term list. Both of these were `existsSync`, which
|
|
643
|
+
// answers false for `EACCES` — so an unreadable workspace was reported as *no workspace.json* and
|
|
644
|
+
// an unreadable report as *no report*, each sending the reader to the wrong fix. Found by review on
|
|
645
|
+
// this pull request, in the change whose own comments name the class for the seam scan two hundred
|
|
646
|
+
// lines up: a rule enforced at one site of an operation and not its neighbour is `0020`'s shape,
|
|
647
|
+
// committed inside the change that quotes `0020`.
|
|
648
|
+
const manifest = path.join(workspaceDir, "workspace.json");
|
|
649
|
+
const absent = whyAbsent(manifest);
|
|
650
|
+
if (absent === "ENOENT") {
|
|
651
|
+
warn(`feedback: ${workspaceDir} does not look like a workspace — no workspace.json. Point --into at one.`);
|
|
652
|
+
return 2;
|
|
653
|
+
}
|
|
654
|
+
if (absent) {
|
|
655
|
+
warn(`feedback: ${manifest} could not be read — ${absent}. That is not the same as absent, so this refuses rather than drafting elsewhere.`);
|
|
656
|
+
return 2;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const created = now().toISOString().slice(0, 10);
|
|
660
|
+
const at = path.join(workspaceDir, "feedback", `${created}-${name}.md`);
|
|
661
|
+
const free = whyAbsent(at);
|
|
662
|
+
if (!free) {
|
|
663
|
+
warn(`feedback: ${at} already exists. A report is a file you edit — open that one, or pick another title.`);
|
|
664
|
+
return 2;
|
|
665
|
+
}
|
|
666
|
+
if (free !== "ENOENT") {
|
|
667
|
+
warn(`feedback: ${at} could not be read — ${free}. Refusing to write over a file this cannot see.`);
|
|
668
|
+
return 2;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
writeOrRefuse(at, scaffold(kind, { title, created, failedRecipe, failedExit }), "the report");
|
|
672
|
+
|
|
673
|
+
say(`feedback: wrote ${at}`);
|
|
674
|
+
say("");
|
|
675
|
+
say("Edit it, tick the acknowledgements, then:");
|
|
676
|
+
say(` portulan feedback preview ${at}`);
|
|
677
|
+
say(` portulan feedback send ${at} --approve`);
|
|
678
|
+
return 0;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function report(verb, rest, flags, { say, warn, now, env, exec, facts }) {
|
|
682
|
+
const [target] = rest;
|
|
683
|
+
if (!target) {
|
|
684
|
+
warn(`feedback: ${verb} needs a report file — the one \`draft\` wrote.`);
|
|
685
|
+
return 2;
|
|
686
|
+
}
|
|
687
|
+
const at = path.resolve(target);
|
|
688
|
+
let text;
|
|
689
|
+
try {
|
|
690
|
+
text = fs.readFileSync(at, "utf8");
|
|
691
|
+
} catch (error) {
|
|
692
|
+
warn(`feedback: ${at} could not be read — ${error.code ?? error.message}`);
|
|
693
|
+
return 2;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const parsed = parseReport(text);
|
|
697
|
+
|
|
698
|
+
// Already filed. Refused before anything else, because the answer is the same whatever the rest of
|
|
699
|
+
// the report says and because a duplicate is the failure a user notices in public.
|
|
700
|
+
if (parsed.fields.issue) {
|
|
701
|
+
warn(`feedback: this report was already filed — ${parsed.fields.issue}. Draft a new one to say something new.`);
|
|
702
|
+
return 2;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// THE WORKSPACE IS DERIVED FROM THE REPORT'S PATH, AND A WRONG DERIVATION IS A SEAM FAIL-OPEN.
|
|
706
|
+
//
|
|
707
|
+
// `<workspace>/feedback/<report>` is where `draft` writes, so two levels up is the workspace. Move
|
|
708
|
+
// the report anywhere else and the derivation still produces *a* directory — one with no
|
|
709
|
+
// `seam-terms.txt` in it — and the scan then says **nothing was scanned** while a term list sits in
|
|
710
|
+
// the workspace this report actually belongs to. A green that reports its own coverage is only
|
|
711
|
+
// honest if the coverage sentence is about the right place, and this is the one path where being
|
|
712
|
+
// wrong about it sends unscanned bytes into a public tracker.
|
|
713
|
+
//
|
|
714
|
+
// So the layout is checked rather than assumed, and a report outside it is could-not-run. Found by
|
|
715
|
+
// review on this pull request; it is the seam's own fail-closed rule applied to the step that
|
|
716
|
+
// decides *where the seam lives*, which the first cut assumed instead of establishing.
|
|
717
|
+
const workspaceDir = path.dirname(path.dirname(at));
|
|
718
|
+
if (path.basename(path.dirname(at)) !== "feedback") {
|
|
719
|
+
warn(`feedback: ${at} is not inside a workspace's \`feedback/\` directory.`);
|
|
720
|
+
warn("feedback: this tool locates the workspace — and therefore the seam term list — from the report's path,");
|
|
721
|
+
warn("feedback: so a report kept elsewhere would be scanned against the wrong directory, or against nothing.");
|
|
722
|
+
return 2;
|
|
723
|
+
}
|
|
724
|
+
const governing = whyAbsent(path.join(workspaceDir, "workspace.json"));
|
|
725
|
+
if (governing) {
|
|
726
|
+
warn(`feedback: ${path.join(workspaceDir, "workspace.json")} — ${governing}.`);
|
|
727
|
+
warn("feedback: the report's parent workspace has to be there before this can say which seam applies.");
|
|
728
|
+
return 2;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const context = {
|
|
732
|
+
spec: readSpec(workspaceDir),
|
|
733
|
+
host: flags.host,
|
|
734
|
+
repo: flags.repo ?? defaultRepository(),
|
|
735
|
+
facts,
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
const declared = form(parsed.fields.kind);
|
|
739
|
+
const problems = validate(parsed, declared);
|
|
740
|
+
if (problems.length) {
|
|
741
|
+
for (const problem of problems) warn(`feedback: ${problem}`);
|
|
742
|
+
return 2;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const built = payload(parsed, context);
|
|
746
|
+
if (!built.repo) {
|
|
747
|
+
warn("feedback: no repository to file into — package.json carries no `bugs.url`. Pass --repo <owner/name>.");
|
|
748
|
+
return 2;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const found = locateTerms({ flag: flags["seam-terms"], env, workspaceDir });
|
|
752
|
+
let coverage;
|
|
753
|
+
if (found.terms === null) {
|
|
754
|
+
coverage =
|
|
755
|
+
"seam: no term list configured — NOTHING WAS SCANNED. Looked at --seam-terms, " +
|
|
756
|
+
"$PORTULAN_SEAM_TERMS, and " + path.join(workspaceDir, "seam-terms.txt") + ".";
|
|
757
|
+
} else {
|
|
758
|
+
const hits = scan(built.parts, found.terms);
|
|
759
|
+
if (hits.length) {
|
|
760
|
+
warn(`feedback: the seam scan refused this send — ${found.source}`);
|
|
761
|
+
for (const hit of hits) warn(`feedback: \`${hit.term}\` appears in ${hit.section}`);
|
|
762
|
+
warn("feedback: nothing was sent. Edit the report and run it again.");
|
|
763
|
+
return 1;
|
|
764
|
+
}
|
|
765
|
+
coverage = `seam: clean against ${found.terms.length} term(s) from ${found.source}.`;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
say(`portulan feedback — the payload, exactly as it would be filed`);
|
|
769
|
+
say(`repository: ${built.repo}`);
|
|
770
|
+
say(`title: ${built.title}`);
|
|
771
|
+
say("labels: none — the repository owns its own labels (proposal 0014); the title prefix marks the kind");
|
|
772
|
+
say(`--- body (${Buffer.byteLength(built.body, "utf8")} bytes) ---`);
|
|
773
|
+
say(built.body);
|
|
774
|
+
say("--- end of body ---");
|
|
775
|
+
say(coverage);
|
|
776
|
+
|
|
777
|
+
const seen = digest(built);
|
|
778
|
+
if (verb === "preview") {
|
|
779
|
+
writeOrRefuse(at, stamp(text, { previewed: seen }, ["previewed"]), "the preview stamp");
|
|
780
|
+
say(`previewed: ${seen}`);
|
|
781
|
+
return 0;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (!flags.approve) {
|
|
785
|
+
say("");
|
|
786
|
+
warn("feedback: nothing was sent. Sending is Gated — per action, never inherited from a draft or a preview.");
|
|
787
|
+
warn(`feedback: re-run with --approve when the bytes above are the bytes you mean to publish.`);
|
|
788
|
+
return 2;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// The approval is bound to bytes, not to an intention. See `digest` for why this is a refusal
|
|
792
|
+
// rather than a warning: without it, "the exact payload the user saw first" is a habit.
|
|
793
|
+
if (!parsed.fields.previewed) {
|
|
794
|
+
warn("feedback: this report has not been previewed, so there are no bytes you have approved.");
|
|
795
|
+
warn(`feedback: run \`portulan feedback preview ${at}\` and read them, then send.`);
|
|
796
|
+
return 2;
|
|
797
|
+
}
|
|
798
|
+
if (parsed.fields.previewed !== seen) {
|
|
799
|
+
warn("feedback: the payload has changed since it was previewed — the report, or a fact about this machine.");
|
|
800
|
+
warn(`feedback: previewed ${parsed.fields.previewed}`);
|
|
801
|
+
warn(`feedback: now ${seen}`);
|
|
802
|
+
warn("feedback: nothing was sent. Preview again and read what changed.");
|
|
803
|
+
return 2;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const recent = lastSend(workspaceDir, at);
|
|
807
|
+
if (recent && (now().getTime() - recent.at) / 1000 < COOLDOWN_SECONDS) {
|
|
808
|
+
warn(
|
|
809
|
+
`feedback: a report was filed from this directory ${Math.round((now().getTime() - recent.at) / 1000)}s ago ` +
|
|
810
|
+
`(${recent.issue}). Wait out the ${COOLDOWN_SECONDS}s cooldown — it catches a loop, not a person.`,
|
|
811
|
+
);
|
|
812
|
+
return 2;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const auth = exec("gh", ["auth", "status"]);
|
|
816
|
+
if (auth.error || auth.status === null) {
|
|
817
|
+
warn("feedback: `gh` was not found. This sender files under your own GitHub identity and operates no service of its own.");
|
|
818
|
+
// NOT `--body-file <the report>`. That was the first draft's suggestion and it was wrong in the
|
|
819
|
+
// one way that matters here: the report carries frontmatter, this tool's guidance comments and
|
|
820
|
+
// the raw sections, none of which is the payload — so following it would have published bytes
|
|
821
|
+
// nobody previewed, out of the verb whose whole subject is that they cannot be. Caught by
|
|
822
|
+
// review on this pull request. The two honest routes are the tool and the form.
|
|
823
|
+
warn("feedback: install it and run this again, or paste the body printed above into the form:");
|
|
824
|
+
warn(`feedback: https://github.com/${built.repo}/issues/new/choose — title: ${built.title}`);
|
|
825
|
+
warn(`feedback: the report stays at ${at}. Its payload is the bytes between the markers above, and nothing else in it is sent.`);
|
|
826
|
+
return 2;
|
|
827
|
+
}
|
|
828
|
+
if (auth.status !== 0) {
|
|
829
|
+
warn("feedback: `gh` is installed but not logged in. Run `gh auth login`, then run this again.");
|
|
830
|
+
return 2;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const created = exec("gh", ["issue", "create", "--repo", built.repo, "--title", built.title, "--body-file", "-"], {
|
|
834
|
+
input: built.body,
|
|
835
|
+
});
|
|
836
|
+
if (created.error || created.status !== 0) {
|
|
837
|
+
warn(`feedback: \`gh issue create\` failed — ${(created.stderr || created.error?.message || "").trim() || `exit ${created.status}`}`);
|
|
838
|
+
warn(`feedback: nothing was recorded. The report is still at ${at}.`);
|
|
839
|
+
return 2;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const url = (created.stdout.match(/https:\/\/\S+/g) ?? []).pop();
|
|
843
|
+
if (!url) {
|
|
844
|
+
warn("feedback: `gh` succeeded and printed no issue URL — refusing to record a send this cannot name.");
|
|
845
|
+
return 2;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// THE ONE WRITE THAT CANNOT BE A REFUSAL, because by now the issue exists in the world.
|
|
849
|
+
//
|
|
850
|
+
// `writeOrRefuse` would throw here and the top-level handler would print `feedback: …` and exit 2 —
|
|
851
|
+
// *could not run*, about an operation that ran and filed. Worse, the sentence would send a reader
|
|
852
|
+
// back to try again, and the guard that makes a second send a no-op is exactly what just failed to
|
|
853
|
+
// land. So the send is reported as what it was, the missing guard is named, and the exit code says
|
|
854
|
+
// something is wrong without pretending nothing happened: **1**, a verdict — not 0, which would
|
|
855
|
+
// hide it, and not 2, which would deny the send. Found by review on this pull request.
|
|
856
|
+
try {
|
|
857
|
+
fs.writeFileSync(at, stamp(text, { issue: url, sent: now().toISOString() }));
|
|
858
|
+
} catch (error) {
|
|
859
|
+
say("");
|
|
860
|
+
say(`feedback: FILED ${url} — the issue exists.`);
|
|
861
|
+
warn(`feedback: but ${at} could not be updated — ${error.code ?? error.message}.`);
|
|
862
|
+
warn("feedback: nothing now stops a second send of this report from filing a duplicate.");
|
|
863
|
+
warn(`feedback: add this line to its frontmatter by hand, and the guard is back: issue: ${url}`);
|
|
864
|
+
return 1;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
say("");
|
|
868
|
+
say(`feedback: filed ${url}`);
|
|
869
|
+
say(`feedback: recorded in ${at} — a second send of this report is refused.`);
|
|
870
|
+
return 0;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/** Every reason this report is not ready, in one pass — a user fixing one at a time is a user we lost. */
|
|
874
|
+
function validate(parsed, declared) {
|
|
875
|
+
const problems = [];
|
|
876
|
+
// `draft` refuses an absent or unsluggable title, but a report is a file a human edits and the
|
|
877
|
+
// frontmatter is part of it. Without this, a blanked `title:` files as a bare `[feedback] ` — a
|
|
878
|
+
// public issue with no subject, from the one verb where the artifact is permanent.
|
|
879
|
+
if (!(parsed.fields.title ?? "").trim()) {
|
|
880
|
+
problems.push("`title:` is empty, and the issue's title is the form's prefix plus that line.");
|
|
881
|
+
}
|
|
882
|
+
for (const section of declared.sections) {
|
|
883
|
+
if (section.filled) continue;
|
|
884
|
+
const text = parsed.sections.find((s) => s.label === section.label)?.text ?? "";
|
|
885
|
+
if (section.options) {
|
|
886
|
+
const chosen = text.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
887
|
+
if (chosen.length !== 1 || !section.options.includes(chosen[0])) {
|
|
888
|
+
problems.push(
|
|
889
|
+
`\`${section.label}\` must hold exactly one of these lines and nothing else:\n` +
|
|
890
|
+
section.options.map((option) => ` ${option}`).join("\n"),
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if (section.required && !text) problems.push(`\`${section.label}\` is required and is empty.`);
|
|
896
|
+
}
|
|
897
|
+
for (const ack of declared.acknowledgements) {
|
|
898
|
+
if (!ack.required) continue;
|
|
899
|
+
const said = parsed.acknowledgements.find((a) => a.text === ack.text);
|
|
900
|
+
if (!said) problems.push(`the acknowledgement "${ack.text}" is missing from the report.`);
|
|
901
|
+
else if (!said.ticked) problems.push(`the acknowledgement "${ack.text}" is not ticked.`);
|
|
902
|
+
}
|
|
903
|
+
return problems;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* The workspace's spec version, and the ONE thing this tool reads out of a workspace. Soft rather than
|
|
908
|
+
* fail-closed on purpose: it is a line in an environment block, not a gate, and refusing to send a
|
|
909
|
+
* report because a manifest was unreadable would put the seam's discipline behind a decoration.
|
|
910
|
+
*/
|
|
911
|
+
function readSpec(workspaceDir) {
|
|
912
|
+
try {
|
|
913
|
+
return JSON.parse(fs.readFileSync(path.join(workspaceDir, "workspace.json"), "utf8")).portulan?.spec ?? "unknown";
|
|
914
|
+
} catch {
|
|
915
|
+
return "unknown";
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** The most recent recorded send in this directory, for the cooldown. */
|
|
920
|
+
function lastSend(workspaceDir, except) {
|
|
921
|
+
const dir = path.join(workspaceDir, "feedback");
|
|
922
|
+
let names;
|
|
923
|
+
try {
|
|
924
|
+
names = fs.readdirSync(dir);
|
|
925
|
+
} catch {
|
|
926
|
+
return null;
|
|
927
|
+
}
|
|
928
|
+
let latest = null;
|
|
929
|
+
for (const name of names) {
|
|
930
|
+
const at = path.join(dir, name);
|
|
931
|
+
if (at === except || !name.endsWith(".md")) continue;
|
|
932
|
+
let fields;
|
|
933
|
+
try {
|
|
934
|
+
fields = parseReport(fs.readFileSync(at, "utf8")).fields;
|
|
935
|
+
} catch {
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
if (!fields.sent) continue;
|
|
939
|
+
const when = Date.parse(fields.sent);
|
|
940
|
+
if (Number.isNaN(when)) continue;
|
|
941
|
+
if (!latest || when > latest.at) latest = { at: when, issue: fields.issue ?? at };
|
|
942
|
+
}
|
|
943
|
+
return latest;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Record something in the report's own frontmatter — the local artifact `0014` asks for.
|
|
948
|
+
*
|
|
949
|
+
* `replacing` names keys to drop first, so a second `preview` updates its digest rather than stacking
|
|
950
|
+
* a second `previewed:` line that `parseReport` would silently resolve to the last one.
|
|
951
|
+
*/
|
|
952
|
+
function stamp(text, additions, replacing = []) {
|
|
953
|
+
const lines = text.split(/\r?\n/);
|
|
954
|
+
const close = lines.indexOf("---", 1);
|
|
955
|
+
if (close === -1) throw new FeedbackError("the report's frontmatter block is never closed");
|
|
956
|
+
|
|
957
|
+
// Scoped to the frontmatter. Filtering the whole file would delete a body line that happened to
|
|
958
|
+
// begin `previewed:` — a tool editing prose it was not asked to edit, which is worse than the
|
|
959
|
+
// duplicate key it would be preventing.
|
|
960
|
+
const front = lines.slice(1, close).filter((line) => {
|
|
961
|
+
const key = /^([A-Za-z_][\w-]*)\s*:/.exec(line)?.[1];
|
|
962
|
+
return !(key && replacing.includes(key));
|
|
963
|
+
});
|
|
964
|
+
const added = Object.entries(additions).map(([key, value]) => `${key}: ${value}`);
|
|
965
|
+
return [lines[0], ...front, ...added, ...lines.slice(close)].join("\n");
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
969
|
+
process.exitCode = run(process.argv.slice(2));
|
|
970
|
+
}
|