projectstore-codex 0.0.1 → 0.28.2
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/.codex-plugin/plugin.json +48 -0
- package/README.md +15 -7
- package/bin/projectstore-codex.mjs +88 -0
- package/hooks/hooks.json +59 -0
- package/node_modules/projectstore/.claude-plugin/marketplace.json +40 -0
- package/node_modules/projectstore/.claude-plugin/plugin.json +23 -0
- package/node_modules/projectstore/.mcp.json +14 -0
- package/node_modules/projectstore/AGENTS.md +26 -0
- package/node_modules/projectstore/LICENSE +21 -0
- package/node_modules/projectstore/README.md +284 -0
- package/node_modules/projectstore/agents/archaeologist.md +76 -0
- package/node_modules/projectstore/agents/clerk.md +93 -0
- package/node_modules/projectstore/agents/critic.md +94 -0
- package/node_modules/projectstore/agents/librarian.md +81 -0
- package/node_modules/projectstore/agents/planner.md +80 -0
- package/node_modules/projectstore/agents/reviewer.md +98 -0
- package/node_modules/projectstore/bin/projectstore.mjs +7 -0
- package/node_modules/projectstore/commands/adr.md +57 -0
- package/node_modules/projectstore/commands/agents.md +180 -0
- package/node_modules/projectstore/commands/bind.md +128 -0
- package/node_modules/projectstore/commands/codemap.md +50 -0
- package/node_modules/projectstore/commands/concept.md +17 -0
- package/node_modules/projectstore/commands/doctor.md +166 -0
- package/node_modules/projectstore/commands/epic.md +40 -0
- package/node_modules/projectstore/commands/graph.md +56 -0
- package/node_modules/projectstore/commands/kanban.md +40 -0
- package/node_modules/projectstore/commands/meeting.md +17 -0
- package/node_modules/projectstore/commands/reconcile.md +73 -0
- package/node_modules/projectstore/commands/research.md +17 -0
- package/node_modules/projectstore/commands/review.md +89 -0
- package/node_modules/projectstore/commands/runbook.md +17 -0
- package/node_modules/projectstore/commands/scaffold.md +23 -0
- package/node_modules/projectstore/commands/search.md +22 -0
- package/node_modules/projectstore/commands/spec.md +91 -0
- package/node_modules/projectstore/commands/status.md +27 -0
- package/node_modules/projectstore/commands/statusline.md +46 -0
- package/node_modules/projectstore/commands/story.md +113 -0
- package/node_modules/projectstore/docs/extending.md +172 -0
- package/node_modules/projectstore/docs/getting-started.md +133 -0
- package/node_modules/projectstore/docs/harnesses.md +176 -0
- package/node_modules/projectstore/docs/how-it-works.md +263 -0
- package/node_modules/projectstore/docs/images/loop-light.svg +94 -0
- package/node_modules/projectstore/docs/images/loop.svg +93 -0
- package/node_modules/projectstore/docs/images/statusline-hud.png +0 -0
- package/node_modules/projectstore/docs/images/team-light.svg +79 -0
- package/node_modules/projectstore/docs/images/team.svg +79 -0
- package/node_modules/projectstore/harnesses/claude-code.json +483 -0
- package/node_modules/projectstore/harnesses/codex.json +332 -0
- package/node_modules/projectstore/hooks/hooks.json +59 -0
- package/node_modules/projectstore/hooks/pre-compact.mjs +121 -0
- package/node_modules/projectstore/hooks/session-rules.mjs +63 -0
- package/node_modules/projectstore/hooks/session-start.mjs +301 -0
- package/node_modules/projectstore/hooks/session-stop.mjs +84 -0
- package/node_modules/projectstore/package.json +70 -0
- package/node_modules/projectstore/scaffold/checklists.json +88 -0
- package/node_modules/projectstore/scaffold/headings.json +171 -0
- package/node_modules/projectstore/scaffold/layouts/engineering.json +85 -0
- package/node_modules/projectstore/scripts/binding.mjs +165 -0
- package/node_modules/projectstore/scripts/build-adapters.mjs +264 -0
- package/node_modules/projectstore/scripts/cli.mjs +595 -0
- package/node_modules/projectstore/scripts/codemap.mjs +99 -0
- package/node_modules/projectstore/scripts/diff-refs.mjs +127 -0
- package/node_modules/projectstore/scripts/doctor.mjs +2127 -0
- package/node_modules/projectstore/scripts/draft.mjs +261 -0
- package/node_modules/projectstore/scripts/graph.mjs +219 -0
- package/node_modules/projectstore/scripts/harness.mjs +608 -0
- package/node_modules/projectstore/scripts/install-harness.mjs +1387 -0
- package/node_modules/projectstore/scripts/kanban.mjs +174 -0
- package/node_modules/projectstore/scripts/lib.mjs +3085 -0
- package/node_modules/projectstore/scripts/mcp.mjs +391 -0
- package/node_modules/projectstore/scripts/portable-registration.mjs +198 -0
- package/node_modules/projectstore/scripts/provenance.mjs +375 -0
- package/node_modules/projectstore/scripts/query.mjs +490 -0
- package/node_modules/projectstore/scripts/reconcile.mjs +422 -0
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +141 -0
- package/node_modules/projectstore/scripts/statusline.mjs +253 -0
- package/node_modules/projectstore/scripts/story-section.mjs +209 -0
- package/node_modules/projectstore/scripts/surfaces.mjs +421 -0
- package/node_modules/projectstore/scripts/tokens.mjs +449 -0
- package/node_modules/projectstore/scripts/touch-session.mjs +336 -0
- package/node_modules/projectstore/scripts/version-guard.mjs +261 -0
- package/node_modules/projectstore/scripts/worktree.mjs +109 -0
- package/node_modules/projectstore/skills/projectstore-decision-detector/SKILL.md +40 -0
- package/node_modules/projectstore/skills/projectstore-peer-reviewer/SKILL.md +38 -0
- package/node_modules/projectstore/skills/projectstore-story-completion/SKILL.md +50 -0
- package/node_modules/projectstore/skills/projectstore-vault-communication/SKILL.md +96 -0
- package/node_modules/projectstore/templates/claude-md-block.md.tmpl +26 -0
- package/node_modules/projectstore/templates/de/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/de/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/de/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/de/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/de/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/de/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/de/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/de/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/de/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/de/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/de/strings.json +6 -0
- package/node_modules/projectstore/templates/en/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/en/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/en/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/en/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/en/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/en/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/en/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/en/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/en/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/en/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/en/strings.json +6 -0
- package/node_modules/projectstore/templates/es/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/es/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/es/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/es/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/es/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/es/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/es/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/es/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/es/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/es/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/es/strings.json +6 -0
- package/node_modules/projectstore/templates/fr/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/fr/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/fr/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/fr/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/fr/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/fr/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/fr/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/fr/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/fr/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/fr/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/fr/strings.json +6 -0
- package/node_modules/projectstore/templates/ru/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/ru/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/ru/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/ru/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/ru/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/ru/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/ru/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/ru/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/ru/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/ru/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/ru/strings.json +6 -0
- package/node_modules/projectstore/templates/zh/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/zh/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/zh/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/zh/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/zh/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/zh/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/zh/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/zh/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/zh/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/zh/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/zh/strings.json +6 -0
- package/package.json +35 -14
- package/skills/projectstore-adr/SKILL.md +76 -0
- package/skills/projectstore-agents/SKILL.md +50 -0
- package/skills/projectstore-archaeologist/SKILL.md +109 -0
- package/skills/projectstore-bind/SKILL.md +44 -0
- package/skills/projectstore-clerk/SKILL.md +126 -0
- package/skills/projectstore-codemap/SKILL.md +69 -0
- package/skills/projectstore-concept/SKILL.md +36 -0
- package/skills/projectstore-critic/SKILL.md +127 -0
- package/skills/projectstore-decision-detector/SKILL.md +59 -0
- package/skills/projectstore-doctor/SKILL.md +33 -0
- package/skills/projectstore-epic/SKILL.md +59 -0
- package/skills/projectstore-graph/SKILL.md +75 -0
- package/skills/projectstore-kanban/SKILL.md +60 -0
- package/skills/projectstore-librarian/SKILL.md +114 -0
- package/skills/projectstore-meeting/SKILL.md +36 -0
- package/skills/projectstore-peer-reviewer/SKILL.md +57 -0
- package/skills/projectstore-planner/SKILL.md +113 -0
- package/skills/projectstore-reconcile/SKILL.md +92 -0
- package/skills/projectstore-research/SKILL.md +36 -0
- package/skills/projectstore-review/SKILL.md +108 -0
- package/skills/projectstore-reviewer/SKILL.md +131 -0
- package/skills/projectstore-runbook/SKILL.md +36 -0
- package/skills/projectstore-scaffold/SKILL.md +42 -0
- package/skills/projectstore-search/SKILL.md +41 -0
- package/skills/projectstore-spec/SKILL.md +110 -0
- package/skills/projectstore-status/SKILL.md +47 -0
- package/skills/projectstore-statusline/SKILL.md +29 -0
- package/skills/projectstore-story/SKILL.md +132 -0
- package/skills/projectstore-story-completion/SKILL.md +69 -0
- package/skills/projectstore-vault-communication/SKILL.md +115 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// projectstore — the provenance line: one emitter, one parser, one state ladder.
|
|
2
|
+
//
|
|
3
|
+
// Every file projectstore places into a harness (a Codex prompt, an opencode
|
|
4
|
+
// agent, a generated hook wrapper) carries one line that says what produced
|
|
5
|
+
// it and lets a later run decide whether it may touch the file again:
|
|
6
|
+
//
|
|
7
|
+
// projectstore: v1 src=agents/critic.md@1a2b3c4d5e6f pkg=0.27.2 project="/abs/path" render=9f8e7d6c5b4a — generated; edit the source, then reinstall
|
|
8
|
+
//
|
|
9
|
+
// Two hashes. `src=` is the source bytes, so a stale copy of a changed source
|
|
10
|
+
// is detectable. `render=` is the rendered file with THIS WHOLE LINE replaced
|
|
11
|
+
// by the literal token %PROJECTSTORE-PROVENANCE%, so the hash never covers
|
|
12
|
+
// the hash it is about and no field of the line — `project=` included — can
|
|
13
|
+
// churn it. Rendering is therefore two-pass: render with the token line, hash,
|
|
14
|
+
// substitute. A file two projects rendered identically differs only in the
|
|
15
|
+
// line, which is what makes "current, last written by <project>" reportable.
|
|
16
|
+
//
|
|
17
|
+
// This module is deliberately a LEAF. It imports node:crypto and nothing else;
|
|
18
|
+
// it opens no file, reads no environment variable and has no clock. Every
|
|
19
|
+
// "current" input the state ladder needs — the source hash, the package
|
|
20
|
+
// version, the hash of what we would render now — is handed in by the
|
|
21
|
+
// caller, so the ladder is a pure function testable with string literals.
|
|
22
|
+
// It is not part of scripts/lib.mjs on purpose: lib.mjs is parsed on every
|
|
23
|
+
// SessionStart, and the installer and doctor are the only consumers here.
|
|
24
|
+
// The direction is installer → provenance ← doctor; nothing in this file may
|
|
25
|
+
// ever import the installer, harness.mjs or lib.mjs.
|
|
26
|
+
//
|
|
27
|
+
// Normative: the spec "Installing, refreshing and disowning a harness
|
|
28
|
+
// surface" (contract 1 — the grammar; 3 and 4 — the four states and the
|
|
29
|
+
// ordered derivation) and "Generated harness surfaces" (contract 15 — every
|
|
30
|
+
// generated non-JSON file is stamped, and the banner says to edit the source).
|
|
31
|
+
// The two-hash line, the two-pass render and the four-state model are
|
|
32
|
+
// contributed by Ivan Morozov (MultiProjectStore, scripts/agents.mjs:
|
|
33
|
+
// provenance(), renderHashOf(), status()); the generated-file banner and its
|
|
34
|
+
// three per-format renderers are contributed by Maxim Podreshetnikov (PR #13,
|
|
35
|
+
// scripts/build-adapters.mjs: BANNER_LINES, mdBanner, hashBanner,
|
|
36
|
+
// slashBanner). `v1`, `pkg=`, `project=` and the four-reason stale split are
|
|
37
|
+
// ours.
|
|
38
|
+
|
|
39
|
+
import { createHash } from "node:crypto";
|
|
40
|
+
|
|
41
|
+
// ─── Constants ─────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export const GRAMMAR_VERSION = 1;
|
|
44
|
+
export const PROVENANCE_TOKEN = "%PROJECTSTORE-PROVENANCE%";
|
|
45
|
+
// The project path is provenance data (the line's project= field) and may also
|
|
46
|
+
// appear in the body — the status-line launcher names its project since
|
|
47
|
+
// 2026-09-06. It is neutralised before the render hash, so a file another
|
|
48
|
+
// project wrote at our path still reads "current, last written by <project>"
|
|
49
|
+
// (install spec, contract 12) instead of "edited by hand".
|
|
50
|
+
export const PROJECT_TOKEN = "%PROJECTSTORE-PROJECT%";
|
|
51
|
+
export function neutralizeProject(text, project) {
|
|
52
|
+
if (typeof text !== "string" || typeof project !== "string" || !project) return text;
|
|
53
|
+
return text.split(JSON.stringify(project)).join(PROJECT_TOKEN);
|
|
54
|
+
}
|
|
55
|
+
export const HASH_LEN = 12;
|
|
56
|
+
export const DEFAULT_GENERATOR = "scripts/build-adapters.mjs";
|
|
57
|
+
|
|
58
|
+
// Per-format delimiters around the provenance line. `json` is null on
|
|
59
|
+
// purpose: a format that cannot carry a comment is a SHARED surface (install
|
|
60
|
+
// spec contracts 2 and 6), owned per entry, and is never stamped — emitting
|
|
61
|
+
// an empty banner for it would let a caller ship an unowned file in silence.
|
|
62
|
+
export const DELIMITERS = Object.freeze({
|
|
63
|
+
markdown: Object.freeze(["<!-- ", " -->"]),
|
|
64
|
+
toml: Object.freeze(["# ", ""]),
|
|
65
|
+
// The hook wrappers are JavaScript, where `#` is a syntax error rather than
|
|
66
|
+
// a comment. PR #13 records that emitting the hash banner into the wrapper
|
|
67
|
+
// produced a file that parsed nowhere — hence its third renderer.
|
|
68
|
+
mjs: Object.freeze(["// ", ""]),
|
|
69
|
+
json: null,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export const STALE = Object.freeze({
|
|
73
|
+
EDITED: "edited-by-hand",
|
|
74
|
+
SOURCE: "source-changed",
|
|
75
|
+
PLUGIN: "plugin-updated",
|
|
76
|
+
CONFIG: "configuration-changed",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Doctor's exact wording, in one place, so the report and the tests read the
|
|
80
|
+
// same source. A stale finding with no reason is a bug report the user cannot
|
|
81
|
+
// act on (install spec, contract 4).
|
|
82
|
+
export const STALE_TEXT = Object.freeze({
|
|
83
|
+
[STALE.EDITED]: "edited by hand",
|
|
84
|
+
[STALE.SOURCE]: "source changed",
|
|
85
|
+
[STALE.PLUGIN]: "plugin updated",
|
|
86
|
+
[STALE.CONFIG]: "configuration changed",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Contract 5's resolution wording (Ivan Morozov's), in one place for the
|
|
90
|
+
// verbs that refuse and the report that names.
|
|
91
|
+
export const FOREIGN_TEXT = "a file we did not write sits at our path — rename it if it is yours, or delete it to let install take the name";
|
|
92
|
+
|
|
93
|
+
const normalizeEol = (s) => s.replace(/\r\n/g, "\n");
|
|
94
|
+
|
|
95
|
+
function unsupportedFormat(format) {
|
|
96
|
+
return new Error(
|
|
97
|
+
`format "${format}" cannot carry a provenance line — a JSON surface is a ` +
|
|
98
|
+
`SHARED surface (install spec contracts 2 and 6), owned per entry, not stamped; ` +
|
|
99
|
+
`known stampable formats: ${Object.keys(DELIMITERS).filter((k) => DELIMITERS[k]).join(", ")}`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function delimitersFor(format) {
|
|
104
|
+
const d = DELIMITERS[format];
|
|
105
|
+
if (!d) throw unsupportedFormat(format);
|
|
106
|
+
return d;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── Hashing ───────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
// The one truncation point. `src=` and `render=` must never truncate
|
|
112
|
+
// differently, and the parser's `[0-9a-f]{12}` is this length.
|
|
113
|
+
export function hash12(text) {
|
|
114
|
+
if (typeof text !== "string") {
|
|
115
|
+
// A caller bug (an unread file, an undefined field) must not produce a
|
|
116
|
+
// well-formed hash that looks exactly like a legitimate one.
|
|
117
|
+
throw new TypeError(`hash12: expected a string, got ${text === null ? "null" : typeof text}`);
|
|
118
|
+
}
|
|
119
|
+
return createHash("sha256").update(text).digest("hex").slice(0, HASH_LEN);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The source bytes, as read by the caller. EOL-normalised like renderHash,
|
|
123
|
+
// for the same reason: a CRLF checkout must not report every installed file
|
|
124
|
+
// as "source changed" one ladder step after "edited by hand" was ruled out.
|
|
125
|
+
export function sourceHash(text) {
|
|
126
|
+
return hash12(normalizeEol(String(text)));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The ONLY function that computes `render=`. Both the emitting pass and the
|
|
130
|
+
// checking pass go through it, so they cannot disagree — and the EOL
|
|
131
|
+
// normalisation lives here, once, so a Windows checkout with autocrlf does not
|
|
132
|
+
// report every installed file as "edited by hand".
|
|
133
|
+
export function renderHash(tokenizedText) {
|
|
134
|
+
return hash12(normalizeEol(String(tokenizedText)));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── Emit ──────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
// The bare line, no delimiters. One emitter (install spec, contract 1).
|
|
140
|
+
// `project` is JSON-quoted because real vault and project paths carry spaces
|
|
141
|
+
// (and the maintainer's own does); the parser JSON-decodes it back.
|
|
142
|
+
export function provenanceLine({ src, srcHash, pkg, project, render }) {
|
|
143
|
+
for (const [k, v] of Object.entries({ src, srcHash, pkg, project, render })) {
|
|
144
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
145
|
+
throw new Error(`provenanceLine: field "${k}" must be a non-empty string`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// `src` and `pkg` are bare tokens in the grammar (`\S+`). A value with
|
|
149
|
+
// whitespace would be emitted, fail to parse back, derive `foreign`, and
|
|
150
|
+
// make our own file immune to uninstall — refuse it here instead.
|
|
151
|
+
for (const [k, v] of Object.entries({ src, pkg })) {
|
|
152
|
+
if (/\s/.test(v)) throw new Error(`provenanceLine: field "${k}" may not contain whitespace (got ${JSON.stringify(v)})`);
|
|
153
|
+
}
|
|
154
|
+
if (!/^[0-9a-f]{12}$/.test(srcHash) || !/^[0-9a-f]{12}$/.test(render)) {
|
|
155
|
+
throw new Error("provenanceLine: srcHash and render must be 12 lowercase hex characters");
|
|
156
|
+
}
|
|
157
|
+
return (
|
|
158
|
+
`projectstore: v${GRAMMAR_VERSION} src=${src}@${srcHash} pkg=${pkg} ` +
|
|
159
|
+
`project=${JSON.stringify(project)} render=${render} — generated; edit the source, then reinstall`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const DEFAULT_REMEDY = "tests/portability.test.mjs fails while this file is out of date.";
|
|
164
|
+
|
|
165
|
+
// The third line is the remedy, and it differs by who wrote the file: a
|
|
166
|
+
// committed generated tree is caught by the portability suite, an installed
|
|
167
|
+
// file on a user's machine is caught by doctor and refreshed by install.
|
|
168
|
+
export const BANNER_LINES = (harness, source, generator = DEFAULT_GENERATOR, remedy = DEFAULT_REMEDY) => [
|
|
169
|
+
`GENERATED by ${generator} from ${source} for the ${harness} harness.`,
|
|
170
|
+
"Do not edit this file — edit the source and re-run the generator.",
|
|
171
|
+
remedy,
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
// The generated-file banner for one format. Markdown gets a block comment,
|
|
175
|
+
// the line formats get one comment per line.
|
|
176
|
+
export function banner(format, { harness, source, generator = DEFAULT_GENERATOR, remedy = DEFAULT_REMEDY }) {
|
|
177
|
+
const lines = BANNER_LINES(harness, source, generator, remedy);
|
|
178
|
+
if (format === "markdown") {
|
|
179
|
+
return "<!--\n" + lines.map((l) => " " + l).join("\n") + "\n-->\n";
|
|
180
|
+
}
|
|
181
|
+
const [open] = delimitersFor(format);
|
|
182
|
+
return lines.map((l) => open + l).join("\n") + "\n";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Where the stamp goes: after a leading frontmatter block for markdown (the
|
|
186
|
+
// same `---` shape lib.mjs parseFrontmatter recognises — a close with or
|
|
187
|
+
// without a trailing newline; this detector must agree with it, not improve on
|
|
188
|
+
// it), after a shebang for `.mjs`, else line 1. `body` is already
|
|
189
|
+
// EOL-normalised by the caller. A markdown body that opens a frontmatter block
|
|
190
|
+
// and never closes it is refused rather than stamped at line 1: a banner
|
|
191
|
+
// before the `---` is a file the harness silently stops loading.
|
|
192
|
+
function insertionOffset(body, format) {
|
|
193
|
+
if (format === "markdown") {
|
|
194
|
+
// The optional group admits an empty block (`---\n---`), which
|
|
195
|
+
// parseFrontmatter does not match at all — that is the one place this
|
|
196
|
+
// detector is wider, so that a harness reading it as frontmatter never
|
|
197
|
+
// sees our banner inside it.
|
|
198
|
+
const m = /^---\n(?:[\s\S]*?\n)?---(?:\n|$)/.exec(body);
|
|
199
|
+
if (m) return m[0].length;
|
|
200
|
+
if (body.startsWith("---\n") || body === "---") {
|
|
201
|
+
throw new Error("insertStamp: markdown body opens a frontmatter block that never closes — refusing to stamp before it");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (format === "mjs" && body.startsWith("#!")) {
|
|
205
|
+
const nl = body.indexOf("\n");
|
|
206
|
+
return nl === -1 ? body.length : nl + 1;
|
|
207
|
+
}
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const bareTokenRe = () => new RegExp(`^${PROVENANCE_TOKEN.replace(/[%]/g, "\\$&")}$`, "m");
|
|
212
|
+
|
|
213
|
+
// Pass 1: banner plus the bare token ALONE on its own physical line, with no
|
|
214
|
+
// delimiter and no indentation — because the checker replaces the entire
|
|
215
|
+
// physical line, delimiters included, with the bare token, and the two
|
|
216
|
+
// must hash the same. The body is EOL-normalised here, once: what we render
|
|
217
|
+
// is ours to normalise, and every hash path already assumes LF.
|
|
218
|
+
export function insertStamp(body, { format, harness, source, generator = DEFAULT_GENERATOR, remedy = DEFAULT_REMEDY }) {
|
|
219
|
+
delimitersFor(format);
|
|
220
|
+
const text = normalizeEol(String(body));
|
|
221
|
+
if (bareTokenRe().test(text)) {
|
|
222
|
+
throw new Error(`insertStamp: the body already carries a bare ${PROVENANCE_TOKEN} line — it would survive into the installed file`);
|
|
223
|
+
}
|
|
224
|
+
const at = insertionOffset(text, format);
|
|
225
|
+
const block = banner(format, { harness, source, generator, remedy }) + PROVENANCE_TOKEN + "\n";
|
|
226
|
+
// A frontmatter close with no trailing newline (which parseFrontmatter
|
|
227
|
+
// accepts) still needs the banner on its own line.
|
|
228
|
+
const glue = at > 0 && text[at - 1] !== "\n" ? "\n" : "";
|
|
229
|
+
return text.slice(0, at) + glue + block + text.slice(at);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Pass 2: the token line becomes the delimited real line.
|
|
233
|
+
export function substituteProvenance(tokenized, { format, src, srcHash, pkg, project, render }) {
|
|
234
|
+
const [open, close] = delimitersFor(format);
|
|
235
|
+
const line = open + provenanceLine({ src, srcHash, pkg, project, render }) + close;
|
|
236
|
+
const re = bareTokenRe();
|
|
237
|
+
if (!re.test(tokenized)) {
|
|
238
|
+
throw new Error("substituteProvenance: no bare token line to substitute — run insertStamp first");
|
|
239
|
+
}
|
|
240
|
+
return String(tokenized).replace(re, () => line);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// The whole two-pass render. Returns the stamped text, the hash that went into
|
|
244
|
+
// `render=`, and the tokenized pass-1 text — so a generator that also needs
|
|
245
|
+
// "what would we render now" has it without rendering twice.
|
|
246
|
+
//
|
|
247
|
+
// The hash input is the pass-1 text run through the SAME tokenizer the checker
|
|
248
|
+
// uses. A body that quotes a grammar-shaped line (commands/doctor.md will,
|
|
249
|
+
// per install contract 5) would otherwise hash differently on write and on
|
|
250
|
+
// read and be born "edited by hand" — a false accusation reinstalling cannot
|
|
251
|
+
// clear. The cost: an edit confined to such a quoted line does not move
|
|
252
|
+
// `render=`; strictly narrower harm than a permanent false positive.
|
|
253
|
+
export function stamp(body, { format, src, srcHash, pkg, project, harness, generator = DEFAULT_GENERATOR, remedy = DEFAULT_REMEDY }) {
|
|
254
|
+
const tokenized = insertStamp(body, { format, harness, source: src, generator, remedy });
|
|
255
|
+
const render = renderHash(neutralizeProject(tokenizeProvenance(tokenized) ?? tokenized, project));
|
|
256
|
+
const text = substituteProvenance(tokenized, { format, src, srcHash, pkg, project, render });
|
|
257
|
+
return { text, render, tokenized };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── Parse ─────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
// One grammar string, two RegExp constructors — never one shared /g literal,
|
|
263
|
+
// whose lastIndex survives between calls. `[^\n]*?` … `[^\n]*$` make the match
|
|
264
|
+
// cover the whole physical line, whatever delimiter wraps it; `src=(\S+?)` is
|
|
265
|
+
// lazy because a greedy `\S+` swallows `@<hash>`.
|
|
266
|
+
const GRAMMAR =
|
|
267
|
+
String.raw`^[^\n]*?projectstore: v(\d+) src=(\S+?)@([0-9a-f]{12}) pkg=(\S+) ` +
|
|
268
|
+
String.raw`project="((?:[^"\\]|\\.)*)" render=([0-9a-f]{12})[^\n]*$`;
|
|
269
|
+
const parseRe = () => new RegExp(GRAMMAR, "m");
|
|
270
|
+
const tokenizeRe = () => new RegExp(GRAMMAR, "gm");
|
|
271
|
+
|
|
272
|
+
// The first provenance line in `text`, decoded; null when there is none.
|
|
273
|
+
export function parseProvenance(text) {
|
|
274
|
+
if (typeof text !== "string") return null;
|
|
275
|
+
const m = parseRe().exec(normalizeEol(text));
|
|
276
|
+
if (!m) return null;
|
|
277
|
+
let project;
|
|
278
|
+
try {
|
|
279
|
+
project = JSON.parse(`"${m[5]}"`);
|
|
280
|
+
} catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
v: Number(m[1]),
|
|
285
|
+
src: m[2],
|
|
286
|
+
srcHash: m[3],
|
|
287
|
+
pkg: m[4],
|
|
288
|
+
project,
|
|
289
|
+
render: m[6],
|
|
290
|
+
line: m[0],
|
|
291
|
+
index: m.index,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Every provenance line replaced by the bare token (all of them, so a pasted
|
|
296
|
+
// duplicate still hashes deterministically and reports "edited by hand"
|
|
297
|
+
// rather than depending on which line won). Null when there is none.
|
|
298
|
+
export function tokenizeProvenance(text) {
|
|
299
|
+
if (typeof text !== "string") return null;
|
|
300
|
+
const normalized = normalizeEol(text);
|
|
301
|
+
if (!parseRe().test(normalized)) return null;
|
|
302
|
+
const parsed = parseProvenance(normalized);
|
|
303
|
+
return neutralizeProject(normalized.replace(tokenizeRe(), PROVENANCE_TOKEN), parsed && parsed.project);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// The claimed `render=` against what the file hashes to now.
|
|
307
|
+
export function renderHashOf(text) {
|
|
308
|
+
const parsed = parseProvenance(text);
|
|
309
|
+
if (!parsed) return null;
|
|
310
|
+
return { claimed: parsed.render, actual: renderHash(tokenizeProvenance(text)) };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ─── Derive (install spec, contracts 3 and 4) ──────────────────────────
|
|
314
|
+
|
|
315
|
+
const stripTrailingSlash = (p) => (p.length > 1 ? p.replace(/[\\/]+$/, "") : p);
|
|
316
|
+
|
|
317
|
+
// The four states as a total, ordered function over explicit inputs:
|
|
318
|
+
//
|
|
319
|
+
// file { present: boolean, text: string | null } — null text = unreadable
|
|
320
|
+
// sourceHash hash12 of the current source bytes (caller reads them)
|
|
321
|
+
// pkg the current package version (caller resolves it)
|
|
322
|
+
// renderNowHash renderHash of what we would render now — stamp().render
|
|
323
|
+
// project this project's absolute path
|
|
324
|
+
//
|
|
325
|
+
// Step 5 compares HASHES, never bytes. Comparing bytes (as MultiProjectStore's
|
|
326
|
+
// status() does) makes step 6 unreachable: on a shared user-level path the
|
|
327
|
+
// `project=` field differs, so the bytes differ, and every file another
|
|
328
|
+
// project wrote would report "configuration changed" instead of "current,
|
|
329
|
+
// last written by <project>". The leaf never decides whether a path is shared
|
|
330
|
+
// — that is manifest data (contract 10) — so it always returns `writtenBy` and
|
|
331
|
+
// `sameProject`, and doctor composes the sentence.
|
|
332
|
+
export function deriveState({ file, sourceHash: srcNow, pkg, renderNowHash, project }) {
|
|
333
|
+
const result = (state, reason = null, provenance = null) => ({
|
|
334
|
+
state,
|
|
335
|
+
reason,
|
|
336
|
+
provenance,
|
|
337
|
+
writtenBy: provenance ? provenance.project : null,
|
|
338
|
+
sameProject: Boolean(
|
|
339
|
+
provenance && typeof project === "string" &&
|
|
340
|
+
stripTrailingSlash(provenance.project) === stripTrailingSlash(project),
|
|
341
|
+
),
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
if (!file || !file.present) return result("absent");
|
|
345
|
+
if (typeof file.text !== "string") return result("foreign");
|
|
346
|
+
|
|
347
|
+
// 1. no parseable line → foreign
|
|
348
|
+
const parsed = parseProvenance(file.text);
|
|
349
|
+
if (!parsed) return result("foreign");
|
|
350
|
+
const provenance = {
|
|
351
|
+
v: parsed.v, src: parsed.src, srcHash: parsed.srcHash,
|
|
352
|
+
pkg: parsed.pkg, project: parsed.project, render: parsed.render,
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
// A newer grammar than this parser knows. The spec does not say; reporting
|
|
356
|
+
// it as foreign would make our own files immune to uninstall after a
|
|
357
|
+
// downgrade (contract 13), so it is the plugin that changed.
|
|
358
|
+
if (parsed.v !== GRAMMAR_VERSION) return result("stale", STALE.PLUGIN, provenance);
|
|
359
|
+
|
|
360
|
+
// 2. claimed render ≠ recomputed(file, line→token) → stale: edited by hand
|
|
361
|
+
const actual = renderHash(tokenizeProvenance(file.text));
|
|
362
|
+
if (parsed.render !== actual) return result("stale", STALE.EDITED, provenance);
|
|
363
|
+
|
|
364
|
+
// 3. parsed src hash ≠ current source hash → stale: source changed
|
|
365
|
+
if (parsed.srcHash !== srcNow) return result("stale", STALE.SOURCE, provenance);
|
|
366
|
+
|
|
367
|
+
// 4. parsed pkg ≠ current package version → stale: plugin updated
|
|
368
|
+
if (parsed.pkg !== pkg) return result("stale", STALE.PLUGIN, provenance);
|
|
369
|
+
|
|
370
|
+
// 5. render-now ≠ file → stale: configuration changed
|
|
371
|
+
if (actual !== renderNowHash) return result("stale", STALE.CONFIG, provenance);
|
|
372
|
+
|
|
373
|
+
// 6. current — and `writtenBy` says who, for a shared path
|
|
374
|
+
return result("current", null, provenance);
|
|
375
|
+
}
|