simt-dev 0.6.3 → 0.7.1
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/CHANGELOG.md +162 -841
- package/README.md +21 -135
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
- package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
- package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
- package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
- package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
- package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
- package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
- package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
- package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
- package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
- package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
- package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
- package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
- package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
- package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
- package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
- package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
- package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
- package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
- package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
- package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
- package/package.json +13 -3
- package/rules/core.md +9 -2
- package/rules/mdc/slice-commit.mdc +57 -4
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +3 -3
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/dart.md +1 -1
- package/rules/stacks/deno.md +1 -1
- package/rules/stacks/dotnet.md +1 -1
- package/rules/stacks/elixir.md +1 -1
- package/rules/stacks/go.md +1 -1
- package/rules/stacks/jvm.md +3 -2
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/ruby.md +1 -1
- package/rules/stacks/rust.md +1 -1
- package/rules/stacks/swift.md +1 -1
- package/rules/stacks/typescript.md +2 -2
- package/rules/stacks/uv.md +3 -2
- package/skills/simt-commands/SKILL.md +70 -0
- package/src/advice/advice.mjs +7 -3
- package/src/agenthooks/decide.mjs +141 -9
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/prompt.mjs +10 -3
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/api.mjs +29 -0
- package/src/argv.mjs +37 -3
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/adapters.json +16 -0
- package/src/bench/corpus.mjs +150 -0
- package/src/bench/grade.mjs +23 -49
- package/src/bench/mine.mjs +247 -36
- package/src/bench/probes.mjs +104 -68
- package/src/bench/process.mjs +128 -0
- package/src/bench/report.mjs +260 -84
- package/src/bench/run.mjs +251 -150
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +44 -3
- package/src/ci/workflow.mjs +46 -14
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +205 -0
- package/src/commands/agenthook.mjs +113 -20
- package/src/commands/bench.mjs +192 -18
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +2 -1
- package/src/commands/compare.mjs +50 -49
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +10 -9
- package/src/commands/distill.mjs +4 -3
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +19 -12
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +98 -28
- package/src/commands/graph.mjs +20 -8
- package/src/commands/harness.mjs +3 -2
- package/src/commands/help.mjs +160 -31
- package/src/commands/hooks.mjs +163 -50
- package/src/commands/ignore.mjs +2 -1
- package/src/commands/impact.mjs +8 -9
- package/src/commands/index.mjs +278 -180
- package/src/commands/init.mjs +171 -38
- package/src/commands/lease.mjs +206 -39
- package/src/commands/lessons.mjs +72 -15
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +47 -27
- package/src/commands/mcp.mjs +0 -1
- package/src/commands/pack.mjs +9 -6
- package/src/commands/plan.mjs +6 -5
- package/src/commands/plugin.mjs +3 -3
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +5 -3
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +124 -33
- package/src/commands/shared.mjs +2 -3
- package/src/commands/skills.mjs +82 -45
- package/src/commands/skillscompile.mjs +12 -12
- package/src/commands/skillsinstall.mjs +14 -2
- package/src/commands/slice.mjs +396 -26
- package/src/commands/spec.mjs +151 -0
- package/src/commands/status.mjs +4 -7
- package/src/commands/sync.mjs +50 -33
- package/src/commands/tests.mjs +11 -11
- package/src/commands/tokens.mjs +2 -2
- package/src/commands/undo.mjs +29 -31
- package/src/commands/upgrade.mjs +40 -4
- package/src/commands/why.mjs +19 -12
- package/src/compare/hooksstate.mjs +23 -1
- package/src/compare/learn.mjs +82 -57
- package/src/compare/parallel.mjs +50 -0
- package/src/compare/row.mjs +26 -3
- package/src/completions.mjs +5 -0
- package/src/context.mjs +64 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +17 -2
- package/src/distill/engine.mjs +5 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +18 -6
- package/src/graph/load.mjs +42 -20
- package/src/hooks/capability.mjs +17 -4
- package/src/hooks/dispatcher.mjs +513 -36
- package/src/hooks/gates.mjs +0 -43
- package/src/hooks/resolve.mjs +11 -10
- package/src/hooks/shim.mjs +17 -9
- package/src/identity.mjs +96 -15
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +307 -47
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +372 -84
- package/src/kernel/card.mjs +37 -12
- package/src/kernel/checks.mjs +44 -9
- package/src/kernel/variants.mjs +27 -16
- package/src/lease.mjs +97 -15
- package/src/ledger/index.mjs +120 -25
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/resources.mjs +12 -13
- package/src/mcp/server.mjs +151 -38
- package/src/mcp/tools.mjs +90 -16
- package/src/measure/session.mjs +3 -2
- package/src/memory/decide.mjs +98 -43
- package/src/memory/lessons.mjs +168 -33
- package/src/name.mjs +9 -0
- package/src/node-version.mjs +37 -0
- package/src/output/index.mjs +111 -3
- package/src/packs/manage.mjs +17 -3
- package/src/packs.mjs +20 -2
- package/src/plan/index.mjs +31 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +6 -2
- package/src/prbody.mjs +7 -5
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/disclosure.mjs +2 -2
- package/src/projection/loaders.mjs +41 -1
- package/src/projection/lock.mjs +31 -14
- package/src/projection/manifest.mjs +89 -53
- package/src/projection/markers.mjs +43 -3
- package/src/projection/mdc.mjs +34 -7
- package/src/projection/project.mjs +162 -18
- package/src/projection/rules.mjs +58 -6
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +88 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +24 -15
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +6 -3
- package/src/secrets.mjs +165 -22
- package/src/state.mjs +76 -0
- package/src/status.mjs +9 -4
- package/src/toolwords.mjs +8 -0
- package/src/tsconfig.mjs +39 -99
- package/src/workspace.mjs +55 -10
- package/templates/plan/tracker.json +1 -1
- package/docs/decisions/README.md +0 -591
package/src/kernel/card.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
|
|
25
25
|
import { ECOSYSTEMS } from '../projection/manifest.mjs';
|
|
26
|
+
import { resolveNodeRunner, runScript } from '../ecosystem/node-runner.mjs';
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* The card's ceiling in bytes (plan 0003 R10). Over it, the least valuable lines are
|
|
@@ -93,10 +94,12 @@ const FOCUSED = [
|
|
|
93
94
|
// The repo's own runner decides the shape. A repository whose test script is vitest
|
|
94
95
|
// or jest takes a file argument through the script; one with no such script gets the
|
|
95
96
|
// built-in runner, which is the only answer that needs nothing installed to already
|
|
96
|
-
// be true.
|
|
97
|
-
template: (s, f, scripts) => {
|
|
97
|
+
// be true. `<manager> test -- <file>` is the ADR-010 shape (never `npx`).
|
|
98
|
+
template: (s, f, scripts, runner) => {
|
|
98
99
|
const test = scripts?.test ?? '';
|
|
99
|
-
|
|
100
|
+
if (!/vitest|jest/.test(test)) return 'node --test {file}';
|
|
101
|
+
const run = runner?.run ?? 'npm';
|
|
102
|
+
return `${run} test -- {file}`;
|
|
100
103
|
},
|
|
101
104
|
},
|
|
102
105
|
];
|
|
@@ -123,15 +126,32 @@ function text(value) {
|
|
|
123
126
|
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
|
|
124
127
|
}
|
|
125
128
|
|
|
126
|
-
/** The repository's own `package.json` scripts,
|
|
129
|
+
/** The repository's own `package.json` scripts, plus the manager that runs them. */
|
|
127
130
|
async function readScripts(root) {
|
|
128
131
|
try {
|
|
129
132
|
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
130
|
-
|
|
133
|
+
const scripts = pkg && typeof pkg.scripts === 'object' && pkg.scripts ? pkg.scripts : {};
|
|
134
|
+
// Q31: the runner is committed evidence (lockfile, then `packageManager`),
|
|
135
|
+
// never an assumption of npm — the card, the gates and CI read one answer.
|
|
136
|
+
const lockfiles = [];
|
|
137
|
+
for (const file of ['pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb', 'package-lock.json']) {
|
|
138
|
+
try {
|
|
139
|
+
await readFile(join(root, file), 'utf8');
|
|
140
|
+
lockfiles.push(file);
|
|
141
|
+
} catch {
|
|
142
|
+
// Absent is the normal case; only present files name the runner.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const runner = resolveNodeRunner({
|
|
146
|
+
facts: [],
|
|
147
|
+
lockfiles,
|
|
148
|
+
packageManager: typeof pkg.packageManager === 'string' ? pkg.packageManager : null,
|
|
149
|
+
});
|
|
150
|
+
return { scripts, runner };
|
|
131
151
|
} catch {
|
|
132
152
|
// No package.json is a normal shape (a Python or Go repository), and an unreadable one
|
|
133
153
|
// is reported by the manifest path. Either way the card has no scripts to draw on.
|
|
134
|
-
return {};
|
|
154
|
+
return { scripts: {}, runner: null };
|
|
135
155
|
}
|
|
136
156
|
}
|
|
137
157
|
|
|
@@ -145,7 +165,7 @@ async function readScripts(root) {
|
|
|
145
165
|
* repository has declared nothing. The last is the same table the tool seeds gates from,
|
|
146
166
|
* so the card and the gate list cannot name two different commands for one repository.
|
|
147
167
|
*/
|
|
148
|
-
function resolveVerb(verb, { manifest, facts, stack, scripts }) {
|
|
168
|
+
function resolveVerb(verb, { manifest, facts, stack, scripts, runner }) {
|
|
149
169
|
const declared = text(manifest?.gates?.commands?.[verb]);
|
|
150
170
|
if (declared) return declared;
|
|
151
171
|
|
|
@@ -154,11 +174,11 @@ function resolveVerb(verb, { manifest, facts, stack, scripts }) {
|
|
|
154
174
|
// is the one a hook and CI run, which is what the card is reporting.
|
|
155
175
|
const scriptNames = verb === 'format' ? ['format:check', 'format'] : [verb];
|
|
156
176
|
for (const name of scriptNames) {
|
|
157
|
-
if (text(scripts?.[name])) return
|
|
177
|
+
if (text(scripts?.[name])) return runScript(runner?.id ?? 'npm', name);
|
|
158
178
|
}
|
|
159
179
|
|
|
160
180
|
if (verb === 'build') {
|
|
161
|
-
if (text(scripts?.build)) return 'npm
|
|
181
|
+
if (text(scripts?.build)) return runScript(runner?.id ?? 'npm', 'build');
|
|
162
182
|
for (const row of BUILDS) {
|
|
163
183
|
if (row.fact && facts.has(row.fact)) return row.command;
|
|
164
184
|
if (row.stack && stack.has(row.stack)) return row.command;
|
|
@@ -217,11 +237,16 @@ function pickDroppable(lines) {
|
|
|
217
237
|
export async function commandCard({ root, manifest = null, stack = [], facts = [] }) {
|
|
218
238
|
const factSet = new Set(facts);
|
|
219
239
|
const stackSet = new Set(stack);
|
|
220
|
-
const scripts = await readScripts(root);
|
|
240
|
+
const { scripts, runner } = await readScripts(root);
|
|
241
|
+
// Q31: the lockfile facts name the runner even when `readScripts` saw no
|
|
242
|
+
// lockfile (a fixture with a bare package.json keeps npm; a pnpm checkout
|
|
243
|
+
// resolves pnpm from its fact). Facts win inside the resolver already.
|
|
244
|
+
const { resolveNodeRunner: resolveRunner } = await import('../ecosystem/node-runner.mjs');
|
|
245
|
+
const effective = runner ?? resolveRunner({ facts: factSet });
|
|
221
246
|
|
|
222
247
|
const lines = [];
|
|
223
248
|
for (const verb of VERBS) {
|
|
224
|
-
const command = resolveVerb(verb, { manifest, facts: factSet, stack: stackSet, scripts });
|
|
249
|
+
const command = resolveVerb(verb, { manifest, facts: factSet, stack: stackSet, scripts, runner: effective });
|
|
225
250
|
if (command) lines.push({ label: verb, command, verb });
|
|
226
251
|
}
|
|
227
252
|
for (const entry of FOCUSED) {
|
|
@@ -231,7 +256,7 @@ export async function commandCard({ root, manifest = null, stack = [], facts = [
|
|
|
231
256
|
if (lines.some((l) => l.kind === entry.kind)) continue;
|
|
232
257
|
lines.push({
|
|
233
258
|
label: `focused test (${entry.kind})`,
|
|
234
|
-
command: entry.template(stackSet, factSet, scripts),
|
|
259
|
+
command: entry.template(stackSet, factSet, scripts, effective),
|
|
235
260
|
kind: entry.kind,
|
|
236
261
|
verb: 'focused',
|
|
237
262
|
});
|
package/src/kernel/checks.mjs
CHANGED
|
@@ -14,6 +14,8 @@ import { readFile, stat } from 'node:fs/promises';
|
|
|
14
14
|
import { relative, resolve, sep } from 'node:path';
|
|
15
15
|
import { promisify } from 'node:util';
|
|
16
16
|
|
|
17
|
+
import { CLI } from '../name.mjs';
|
|
18
|
+
|
|
17
19
|
const exec = promisify(execFile);
|
|
18
20
|
|
|
19
21
|
export const CHECK_MARKER = 'simt-check';
|
|
@@ -48,11 +50,32 @@ export function checkDefect(body) {
|
|
|
48
50
|
return null;
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
/**
|
|
54
|
+
* What a runnable check enforces, in the reader's terms. The bare checker id is
|
|
55
|
+
* vocabulary for the manifest author; the projected region is read by a repository
|
|
56
|
+
* that has no manifest and no checker registry.
|
|
57
|
+
*/
|
|
58
|
+
function checkSummary(check) {
|
|
59
|
+
const arg = check.arg;
|
|
60
|
+
switch (check.name) {
|
|
61
|
+
case 'max-file-lines':
|
|
62
|
+
return `by \`${CLI} check\`: more than ${DEFAULT_WARN} lines warns, more than ${DEFAULT_ERROR} fails (\`limits.fileLines\`)`;
|
|
63
|
+
case 'forbid-regex':
|
|
64
|
+
return `added lines matching \`${arg}\` are rejected`;
|
|
65
|
+
case 'forbidden-path-edit':
|
|
66
|
+
return `\`${arg}\` is not to be edited`;
|
|
67
|
+
case 'required-file':
|
|
68
|
+
return `\`${arg}\` must exist`;
|
|
69
|
+
default:
|
|
70
|
+
return check.name;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
51
74
|
/**
|
|
52
75
|
* Replace each runnable check with one `Enforced:` line. A body with no marker
|
|
53
76
|
* is returned unchanged, so a rule nobody marked does not move a byte.
|
|
54
77
|
*
|
|
55
|
-
* @returns {{body: string, enforced: {name: string}[], changed: boolean}}
|
|
78
|
+
* @returns {{body: string, enforced: {name: string, summary: string}[], changed: boolean}}
|
|
56
79
|
*/
|
|
57
80
|
export function foldChecks(body) {
|
|
58
81
|
if (checkDefect(body) !== null || !HAS_MARKER.test(body)) {
|
|
@@ -73,8 +96,9 @@ export function foldChecks(body) {
|
|
|
73
96
|
}
|
|
74
97
|
const indent = ITEM_START.exec(unit)?.[1] ?? '';
|
|
75
98
|
const bullet = ITEM_START.test(unit) ? '- ' : '';
|
|
76
|
-
|
|
77
|
-
|
|
99
|
+
const summary = checkSummary(parsed);
|
|
100
|
+
kept.push(`${indent}${bullet}Enforced: ${summary}`);
|
|
101
|
+
enforced.push({ name: parsed.name, summary });
|
|
78
102
|
}
|
|
79
103
|
const next = kept.join('\n');
|
|
80
104
|
return { body: next, enforced, changed: next !== body };
|
|
@@ -130,15 +154,24 @@ async function runOne(root, check, manifest) {
|
|
|
130
154
|
return forbiddenPath(check, diff);
|
|
131
155
|
}
|
|
132
156
|
|
|
157
|
+
const TEXT_EXTENSIONS = new Set(['.md', '.txt', '.doc', '.docx', '.rst', '.adoc', '.tex']);
|
|
158
|
+
|
|
159
|
+
function isTextFile(rel) {
|
|
160
|
+
const segments = rel.split('/');
|
|
161
|
+
if (segments.includes('doc') || segments.includes('docs')) return true;
|
|
162
|
+
const dot = rel.lastIndexOf('.');
|
|
163
|
+
return dot !== -1 && TEXT_EXTENSIONS.has(rel.slice(dot).toLowerCase());
|
|
164
|
+
}
|
|
165
|
+
|
|
133
166
|
async function fileLines(root, check, manifest) {
|
|
134
167
|
const limits = manifest?.limits?.fileLines ?? {};
|
|
135
|
-
const
|
|
136
|
-
if (
|
|
168
|
+
const exclude = limits.exclude;
|
|
169
|
+
if (exclude !== undefined && !Array.isArray(exclude)) {
|
|
137
170
|
return [{
|
|
138
171
|
file: 'simt.json',
|
|
139
172
|
status: 'invalid',
|
|
140
|
-
detail: 'limits.fileLines.
|
|
141
|
-
remedy: 'name each
|
|
173
|
+
detail: 'limits.fileLines.exclude must be a list of paths',
|
|
174
|
+
remedy: 'name each excluded path, or remove the key',
|
|
142
175
|
}];
|
|
143
176
|
}
|
|
144
177
|
const warnAt = Number.isInteger(limits.warn) ? limits.warn : DEFAULT_WARN;
|
|
@@ -146,13 +179,15 @@ async function fileLines(root, check, manifest) {
|
|
|
146
179
|
const files = await tracked(root);
|
|
147
180
|
if (files === null) return [];
|
|
148
181
|
const findings = [];
|
|
149
|
-
const prefixes = Array.isArray(
|
|
182
|
+
const prefixes = Array.isArray(exclude) ? exclude.filter((g) => typeof g === 'string' && g !== '') : [];
|
|
183
|
+
const skipText = limits.excludeTextFiles === true;
|
|
150
184
|
for (const rel of files) {
|
|
151
185
|
// A committed dependency is not a file this repository wrote. Counting it
|
|
152
186
|
// fails every install of this package, because the package's own sources
|
|
153
187
|
// are longer than the ceiling the consumer declared.
|
|
154
188
|
if (rel.split('/').includes('node_modules')) continue;
|
|
155
189
|
if (exempt(rel, prefixes)) continue;
|
|
190
|
+
if (skipText && isTextFile(rel)) continue;
|
|
156
191
|
let text;
|
|
157
192
|
try {
|
|
158
193
|
text = await readFile(resolve(root, rel));
|
|
@@ -170,7 +205,7 @@ async function fileLines(root, check, manifest) {
|
|
|
170
205
|
file: rel,
|
|
171
206
|
status: 'file-lines',
|
|
172
207
|
detail: `${lines} lines, over the ceiling of ${errorAt}`,
|
|
173
|
-
remedy: 'split the file, or name
|
|
208
|
+
remedy: 'split the file, or name it in limits.fileLines.exclude',
|
|
174
209
|
});
|
|
175
210
|
} else if (lines > warnAt) {
|
|
176
211
|
findings.push({
|
package/src/kernel/variants.mjs
CHANGED
|
@@ -4,34 +4,45 @@
|
|
|
4
4
|
// A rule line or list item may carry `<!-- simt-variants: <slot> <spec> -->`.
|
|
5
5
|
// When unlocked, projection uses the baseline variant (`a`, or the line's own
|
|
6
6
|
// prose) and strips the marker so `simt-` annotations never reach a reader.
|
|
7
|
-
// When `simt
|
|
7
|
+
// When `simt.lock.json` records a locked winner for `<ruleId>.<slot>`,
|
|
8
8
|
// projection deterministically renders that winning variant.
|
|
9
9
|
|
|
10
10
|
import { readFile } from 'node:fs/promises';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
|
|
13
|
+
import { LOCK_FILE, LOCK_LEGACY } from '../name.mjs';
|
|
14
|
+
|
|
13
15
|
export const VARIANTS_MARKER = 'simt-variants';
|
|
14
|
-
const LOCK_FILE_NAME = 'simt-dev.lock.json';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
|
-
* Read the locked `variants` map from `simt
|
|
18
|
+
* Read the locked `variants` map from `simt.lock.json` in `root`, or `{}` when absent.
|
|
19
|
+
* A lock under the legacy name is read until the next write migrates it (ADR-110); a
|
|
20
|
+
* lock that exists but does not parse is damage, and damage is not repaired by an
|
|
21
|
+
* older file.
|
|
18
22
|
*/
|
|
19
23
|
export async function readLockedVariants(root) {
|
|
20
24
|
if (!root) return {};
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
for (const name of [LOCK_FILE, LOCK_LEGACY]) {
|
|
26
|
+
let raw;
|
|
27
|
+
try {
|
|
28
|
+
raw = await readFile(join(root, name), 'utf8');
|
|
29
|
+
} catch {
|
|
30
|
+
continue; // no lock under this name; try the next one
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(raw);
|
|
34
|
+
if (
|
|
35
|
+
parsed &&
|
|
36
|
+
typeof parsed === 'object' &&
|
|
37
|
+
parsed.variants &&
|
|
38
|
+
typeof parsed.variants === 'object' &&
|
|
39
|
+
!Array.isArray(parsed.variants)
|
|
40
|
+
) {
|
|
41
|
+
return parsed.variants;
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
return {};
|
|
32
45
|
}
|
|
33
|
-
} catch {
|
|
34
|
-
// Missing or unreadable lockfile falls back to baseline variants.
|
|
35
46
|
}
|
|
36
47
|
return {};
|
|
37
48
|
}
|
package/src/lease.mjs
CHANGED
|
@@ -14,13 +14,15 @@
|
|
|
14
14
|
// path to the session that broke it. Which way that fails is the caller's decision —
|
|
15
15
|
// the hook refuses edits, the command refuses to write (ADR-101 rulings 4).
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
18
|
+
import { mkdir, open, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
18
19
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
19
20
|
|
|
20
|
-
import {
|
|
21
|
+
import { localRel, readStateFile } from './state.mjs';
|
|
21
22
|
|
|
22
23
|
/** Where the claims live, in every repository: beside the index and the ledger. */
|
|
23
|
-
export const LEASE_FILE =
|
|
24
|
+
export const LEASE_FILE = localRel('leases.json');
|
|
25
|
+
const LEASE_FILE_LEGACY = '.simt/leases.json';
|
|
24
26
|
|
|
25
27
|
/** The schema the file is written with; a reader that does not know it refuses. */
|
|
26
28
|
const LEASE_SCHEMA = 1;
|
|
@@ -34,18 +36,46 @@ const LEASE_SCHEMA = 1;
|
|
|
34
36
|
export const DEFAULT_TTL_SECONDS = 3600;
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
|
-
* The
|
|
38
|
-
*
|
|
39
|
+
* The one spelling a path has for lease matching: repository-relative, symlinks
|
|
40
|
+
* resolved, case-folded when the repository's git compares case-insensitively, or
|
|
41
|
+
* null when the path is not inside the repository at all.
|
|
39
42
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
43
|
+
* A payload that reaches a file through a symlinked directory spells it one way,
|
|
44
|
+
* the claim recorded for the file spells it the other, and on a case-insensitive
|
|
45
|
+
* volume the two spellings that differ in case are the same file (review 0003
|
|
46
|
+
* Q16). Both sides of a comparison go through this, so a claim and an edit meet
|
|
47
|
+
* in the same spelling whatever road each one took to get there.
|
|
48
|
+
*
|
|
49
|
+
* The real path of the deepest existing ancestor is joined with the remainder,
|
|
50
|
+
* because `realpath` on a file that does not exist yet — the normal state of a
|
|
51
|
+
* write payload — would refuse. The root itself leases nothing — there is no
|
|
52
|
+
* edit that "is" the repository — so it answers null like a path that escapes it.
|
|
44
53
|
*/
|
|
45
|
-
export function
|
|
46
|
-
const
|
|
54
|
+
export function canonicalLeasePath(root, input, { ignoreCase = false } = {}) {
|
|
55
|
+
const base = resolve(root, String(input ?? ''));
|
|
56
|
+
let anchor = base;
|
|
57
|
+
while (anchor !== resolve(anchor, '..')) {
|
|
58
|
+
if (existsSync(anchor)) break;
|
|
59
|
+
anchor = dirname(anchor);
|
|
60
|
+
}
|
|
61
|
+
let realAnchor;
|
|
62
|
+
try {
|
|
63
|
+
realAnchor = realpathSync(anchor);
|
|
64
|
+
} catch {
|
|
65
|
+
realAnchor = anchor;
|
|
66
|
+
}
|
|
67
|
+
const rest = relative(anchor, base);
|
|
68
|
+
const abs = rest === '' ? realAnchor : join(realAnchor, rest);
|
|
69
|
+
let realRoot;
|
|
70
|
+
try {
|
|
71
|
+
realRoot = realpathSync(resolve(root));
|
|
72
|
+
} catch {
|
|
73
|
+
realRoot = resolve(root);
|
|
74
|
+
}
|
|
75
|
+
const rel = relative(realRoot, abs);
|
|
47
76
|
if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`)) return null;
|
|
48
|
-
|
|
77
|
+
const slashed = rel.split(sep).join('/');
|
|
78
|
+
return ignoreCase ? slashed.toLowerCase() : slashed;
|
|
49
79
|
}
|
|
50
80
|
|
|
51
81
|
/**
|
|
@@ -60,13 +90,14 @@ export function leasePathFor(root, input) {
|
|
|
60
90
|
*/
|
|
61
91
|
export async function readLeases(root) {
|
|
62
92
|
const path = join(root, LEASE_FILE);
|
|
93
|
+
// The pre-move path is a fallback for one release (review 0003 Q19).
|
|
63
94
|
let text;
|
|
64
95
|
try {
|
|
65
|
-
text = await
|
|
96
|
+
({ text } = await readStateFile(root, LEASE_FILE, [LEASE_FILE_LEGACY]));
|
|
66
97
|
} catch (error) {
|
|
67
|
-
if (error.code === 'ENOENT') return { path, leases: [], error: null };
|
|
68
98
|
return { path, leases: [], error: `${LEASE_FILE} could not be read (${error.message})` };
|
|
69
99
|
}
|
|
100
|
+
if (text === null) return { path, leases: [], error: null };
|
|
70
101
|
try {
|
|
71
102
|
const parsed = JSON.parse(text);
|
|
72
103
|
if (parsed === null || typeof parsed !== 'object' || !Array.isArray(parsed.leases)) {
|
|
@@ -108,6 +139,57 @@ export async function writeLeases(root, leases) {
|
|
|
108
139
|
const sorted = [...leases].sort((a, b) => a.path.localeCompare(b.path));
|
|
109
140
|
const body = `${JSON.stringify({ schemaVersion: LEASE_SCHEMA, leases: sorted }, null, 2)}\n`;
|
|
110
141
|
await mkdir(dirname(path), { recursive: true });
|
|
111
|
-
|
|
142
|
+
// Atomic against a concurrent writer and a concurrent reader: a half-written
|
|
143
|
+
// claim file fails closed for every edit (review 0003 Q20).
|
|
144
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
145
|
+
await writeFile(tmp, body, 'utf8');
|
|
146
|
+
await rename(tmp, path);
|
|
112
147
|
return path;
|
|
113
148
|
}
|
|
149
|
+
|
|
150
|
+
/** A lock is stale when its holder died holding it, not when it is merely slow. */
|
|
151
|
+
const LOCK_STALE_MS = 10_000;
|
|
152
|
+
const LOCK_WAIT_MS = 10_000;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Run `fn` with the claim file held: an `O_EXCL` lockfile (stale ones reaped),
|
|
156
|
+
* the read-modify-write re-done inside, the lock released afterwards.
|
|
157
|
+
*
|
|
158
|
+
* Twelve concurrent claims stored seven before this: every claimant read the
|
|
159
|
+
* same file, each wrote back only what it had seen. Re-reading under the lock
|
|
160
|
+
* closes that race (review 0003 Q20).
|
|
161
|
+
*/
|
|
162
|
+
export async function withLeaseLock(root, fn) {
|
|
163
|
+
const lockPath = join(root, `${LEASE_FILE}.lock`);
|
|
164
|
+
await mkdir(dirname(lockPath), { recursive: true });
|
|
165
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
166
|
+
for (;;) {
|
|
167
|
+
try {
|
|
168
|
+
const fd = await open(lockPath, 'wx');
|
|
169
|
+
await fd.close();
|
|
170
|
+
break;
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (error.code !== 'EEXIST') throw error;
|
|
173
|
+
let stale = false;
|
|
174
|
+
try {
|
|
175
|
+
const st = await stat(lockPath);
|
|
176
|
+
stale = Date.now() - st.mtimeMs > LOCK_STALE_MS;
|
|
177
|
+
} catch {
|
|
178
|
+
stale = true;
|
|
179
|
+
}
|
|
180
|
+
if (stale) {
|
|
181
|
+
await unlink(lockPath).catch(() => {});
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (Date.now() > deadline) {
|
|
185
|
+
throw new Error(`could not take the lease lock; remove ${lockPath} if no lease run is active`);
|
|
186
|
+
}
|
|
187
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
return await fn();
|
|
192
|
+
} finally {
|
|
193
|
+
await unlink(lockPath).catch(() => {});
|
|
194
|
+
}
|
|
195
|
+
}
|
package/src/ledger/index.mjs
CHANGED
|
@@ -7,26 +7,31 @@
|
|
|
7
7
|
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
9
|
import { execFile } from 'node:child_process';
|
|
10
|
-
import { appendFile, mkdir, mkdtemp,
|
|
10
|
+
import { appendFile, mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
11
11
|
import { tmpdir } from 'node:os';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import { promisify } from 'node:util';
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
|
-
LESSONS_FILE,
|
|
17
16
|
errorSignature,
|
|
18
17
|
firstErrorLine,
|
|
19
18
|
normaliseErrorLine,
|
|
20
19
|
recordFailThenFix,
|
|
21
20
|
} from '../memory/lessons.mjs';
|
|
21
|
+
import { TREE_EXCLUDES, localRel, readStateFile } from '../state.mjs';
|
|
22
22
|
|
|
23
23
|
const exec = promisify(execFile);
|
|
24
24
|
|
|
25
|
-
const LEDGER_FILE = '
|
|
25
|
+
export const LEDGER_FILE = localRel('ledger.jsonl');
|
|
26
|
+
const LEDGER_FILE_LEGACY = '.simt/ledger.jsonl';
|
|
26
27
|
|
|
27
28
|
/** Phrases that claim a run happened. `unverified` and `not verified` are removed
|
|
28
|
-
* first, because both contain the word and neither is a claim.
|
|
29
|
-
|
|
29
|
+
* first, because both contain the word and neither is a claim. Trailers
|
|
30
|
+
* (Signed-off-by and friends) are ignored, because they describe the commit,
|
|
31
|
+
* not the run (review 0003 Q18). */
|
|
32
|
+
const CLAIM =
|
|
33
|
+
/\btests?\s+pass(?:ed|ing)?\b|\btest\s+suite\s+(?:is\s+)?green\b|\bsuite\s+is\s+green\b|\bnpm\s+test\s+pass(?:es|ed)?\b|\bverified\b/i;
|
|
34
|
+
const TRAILER = /^(signed-off-by|co-authored-by|reviewed-by|acked-by|tested-by|fixes|closes)\s*:/i;
|
|
30
35
|
|
|
31
36
|
/**
|
|
32
37
|
* The tree of everything in the worktree, without staging any of it.
|
|
@@ -39,14 +44,18 @@ export async function treeId(root) {
|
|
|
39
44
|
const index = join(dir, 'index');
|
|
40
45
|
const env = { ...process.env, GIT_INDEX_FILE: index };
|
|
41
46
|
try {
|
|
42
|
-
//
|
|
43
|
-
// `git add -A` would take it once the file exists, and the
|
|
44
|
-
// tree that stopped existing when the line was written
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
// Machine-local state is a record of the tree, so it cannot be part of the
|
|
48
|
+
// tree it names. `git add -A` would take it once the file exists, and the
|
|
49
|
+
// line would prove a tree that stopped existing when the line was written
|
|
50
|
+
// (review 0003 Q19).
|
|
51
|
+
//
|
|
52
|
+
// Where .gitignore already keeps a path out of the index, the pathspec
|
|
53
|
+
// would name an ignored path, and `git add` refuses it ("use -f") - and -f
|
|
54
|
+
// would force the very state back into the tree. The excludes therefore
|
|
55
|
+
// carry only the paths git does not already ignore.
|
|
56
|
+
const ignored = await ignoredSet(root, TREE_EXCLUDES);
|
|
57
|
+
const excludes = TREE_EXCLUDES.filter((p) => !ignored.has(p)).map((p) => `:(exclude)${p}`);
|
|
58
|
+
await exec('git', ['add', '-A', '--', '.', ...excludes], { cwd: root, env });
|
|
50
59
|
const { stdout } = await exec('git', ['write-tree'], { cwd: root, env });
|
|
51
60
|
return stdout.trim();
|
|
52
61
|
} finally {
|
|
@@ -54,16 +63,58 @@ export async function treeId(root) {
|
|
|
54
63
|
}
|
|
55
64
|
}
|
|
56
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The paths git already ignores, so the exclude pathspecs do not name them.
|
|
68
|
+
* A repository with no .gitignore ignores nothing, and every exclude ships
|
|
69
|
+
* exactly as before.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} root
|
|
72
|
+
* @param {string[]} paths
|
|
73
|
+
* @returns {Promise<Set<string>>}
|
|
74
|
+
*/
|
|
75
|
+
async function ignoredSet(root, paths) {
|
|
76
|
+
try {
|
|
77
|
+
// Paths as argv, not `--stdin`: `promisify(execFile)` drops the `input`
|
|
78
|
+
// option, so a stdin-fed `check-ignore` waits for input that never comes
|
|
79
|
+
// and the promise never settles.
|
|
80
|
+
const { stdout } = await exec('git', ['check-ignore', ...paths], { cwd: root });
|
|
81
|
+
return new Set(String(stdout).split('\n').filter(Boolean));
|
|
82
|
+
} catch {
|
|
83
|
+
// `check-ignore` exits 1 when nothing is ignored - the common case, not an
|
|
84
|
+
// error. Failing to the full exclude list is the pre-filter behaviour.
|
|
85
|
+
return new Set();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
57
89
|
/**
|
|
58
90
|
* Append one run. The output itself is not stored: the digest is enough to notice a
|
|
59
91
|
* change, and a ledger that kept logs would keep whatever the command printed.
|
|
60
92
|
*/
|
|
61
|
-
|
|
93
|
+
/** The staged tree (`git write-tree` on the real index): what a commit-msg
|
|
94
|
+
* check must match, because the commit records the index, not the worktree.
|
|
95
|
+
* Null when git cannot answer; the caller then falls back to the worktree tree. */
|
|
96
|
+
export async function indexTreeId(root) {
|
|
97
|
+
try {
|
|
98
|
+
const { stdout } = await exec('git', ['write-tree'], { cwd: root });
|
|
99
|
+
const tree = String(stdout ?? '').trim();
|
|
100
|
+
return /^[0-9a-f]{40}$/.test(tree) ? tree : null;
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function recordRun(root, { cmd, exit, ms, output, gate = null, kind = null }) {
|
|
62
107
|
const { entries: prior } = await readLedger(root).catch(() => ({ entries: [] }));
|
|
108
|
+
const worktreeTree = await treeId(root);
|
|
109
|
+
const indexTree = await indexTreeId(root);
|
|
63
110
|
const line = {
|
|
64
|
-
v:
|
|
111
|
+
v: 2,
|
|
65
112
|
ts: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
66
|
-
tree:
|
|
113
|
+
tree: worktreeTree,
|
|
114
|
+
worktreeTree,
|
|
115
|
+
...(indexTree !== null ? { indexTree } : {}),
|
|
116
|
+
...(typeof gate === 'string' ? { gate } : {}),
|
|
117
|
+
...(typeof kind === 'string' ? { kind } : {}),
|
|
67
118
|
cmd,
|
|
68
119
|
exit,
|
|
69
120
|
ms,
|
|
@@ -76,7 +127,7 @@ export async function recordRun(root, { cmd, exit, ms, output }) {
|
|
|
76
127
|
}
|
|
77
128
|
: {}),
|
|
78
129
|
};
|
|
79
|
-
await mkdir(join(root, '.simt'), { recursive: true });
|
|
130
|
+
await mkdir(join(root, '.simt', 'local'), { recursive: true });
|
|
80
131
|
await appendFile(join(root, LEDGER_FILE), `${JSON.stringify(line)}\n`);
|
|
81
132
|
|
|
82
133
|
if (exit === 0 && typeof cmd === 'string') {
|
|
@@ -100,13 +151,9 @@ export async function recordRun(root, { cmd, exit, ms, output }) {
|
|
|
100
151
|
/** Every well-formed line. A line that does not parse is returned as an error so a
|
|
101
152
|
* damaged ledger cannot look empty. */
|
|
102
153
|
export async function readLedger(root) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
} catch (error) {
|
|
107
|
-
if (error.code === 'ENOENT') return { entries: [], errors: [] };
|
|
108
|
-
throw error;
|
|
109
|
-
}
|
|
154
|
+
// The pre-move path is a fallback for one release (review 0003 Q19).
|
|
155
|
+
const { text } = await readStateFile(root, LEDGER_FILE, [LEDGER_FILE_LEGACY]);
|
|
156
|
+
if (text === null) return { entries: [], errors: [] };
|
|
110
157
|
const entries = [];
|
|
111
158
|
const errors = [];
|
|
112
159
|
for (const [i, raw] of text.split('\n').entries()) {
|
|
@@ -125,8 +172,56 @@ export async function readLedger(root) {
|
|
|
125
172
|
return { entries, errors };
|
|
126
173
|
}
|
|
127
174
|
|
|
175
|
+
/** Whether the entry counts as a test-kind run (review 0003 Q18). v1 lines
|
|
176
|
+
* carry no kind, so a command naming test counts; v2 lines use the recorded kind. */
|
|
177
|
+
function isTestKind(entry) {
|
|
178
|
+
if (typeof entry?.kind === 'string') return entry.kind === 'test';
|
|
179
|
+
return typeof entry?.cmd === 'string' && /\btest\b/i.test(entry.cmd);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The surviving passing entry for one gate on one staged tree, or null.
|
|
184
|
+
*
|
|
185
|
+
* A later failure for the same gate on the same tree cancels the pass, the
|
|
186
|
+
* way `hasTestEvidence` cancels a test pass — a gate that passed, then
|
|
187
|
+
* failed, proves nothing about the tree. Entries without an `indexTree`
|
|
188
|
+
* predate the staged-tree record and never match: a pass on an unknown tree
|
|
189
|
+
* is not evidence for this one (review 0003 Q23).
|
|
190
|
+
*/
|
|
191
|
+
export function passEntryForGate(entries, gate, tree) {
|
|
192
|
+
const scoped = (entries ?? []).filter((e) => e?.gate === gate && e?.indexTree === tree);
|
|
193
|
+
for (let i = 0; i < scoped.length; i += 1) {
|
|
194
|
+
if (scoped[i].exit !== 0) continue;
|
|
195
|
+
const laterFailure = scoped.slice(i + 1).some((later) => later.exit !== 0);
|
|
196
|
+
if (!laterFailure) return scoped[i];
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** A passing test-kind entry for `tree` with no later failure of the same
|
|
202
|
+
* command on the same tree. v1 lines (no `kind`) keep today's reading: any
|
|
203
|
+
* passing entry for the tree counts, so old ledgers do not go red. */
|
|
204
|
+
export function hasTestEvidence(entries, tree) {
|
|
205
|
+
const scoped = entries.filter((e) => e.tree === tree || e.worktreeTree === tree || e.indexTree === tree);
|
|
206
|
+
for (let i = 0; i < scoped.length; i += 1) {
|
|
207
|
+
const candidate = scoped[i];
|
|
208
|
+
if (candidate.exit !== 0) continue;
|
|
209
|
+
const legacy = candidate.v !== 2 || candidate.kind === undefined || candidate.kind === null;
|
|
210
|
+
if (!legacy && !isTestKind(candidate)) continue;
|
|
211
|
+
const laterFailure = scoped
|
|
212
|
+
.slice(i + 1)
|
|
213
|
+
.some((later) => later.cmd === candidate.cmd && later.exit !== 0);
|
|
214
|
+
if (!laterFailure) return true;
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
128
219
|
export function claimsVerification(message) {
|
|
129
|
-
const
|
|
220
|
+
const lines = String(message).split('\n').filter((line) => !TRAILER.test(line.trim()));
|
|
221
|
+
const text = lines
|
|
222
|
+
.join('\n')
|
|
223
|
+
.replace(/\bunverified\b/gi, ' ')
|
|
224
|
+
.replace(/\bnot\s+verified\b/gi, ' ');
|
|
130
225
|
return CLAIM.test(text);
|
|
131
226
|
}
|
|
132
227
|
|
package/src/local/client.mjs
CHANGED
|
@@ -18,14 +18,12 @@ function guardEndpoint(endpoint, { allowRemote = false } = {}) {
|
|
|
18
18
|
if (!endpoint || typeof endpoint !== 'object') {
|
|
19
19
|
return { ok: false, error: 'local model endpoint is not configured', refusedRemote: false };
|
|
20
20
|
}
|
|
21
|
+
// `url` and `model` are validated once, where the config is read
|
|
22
|
+
// (`local/config.mjs`): an endpoint that reaches this function has both. What is left
|
|
23
|
+
// here is the one rule the config cannot enforce - a loopback-only default - and that
|
|
24
|
+
// is why this function still exists (review 0003 Q83).
|
|
21
25
|
const rawUrl = typeof endpoint.url === 'string' ? endpoint.url.trim() : '';
|
|
22
|
-
if (!rawUrl) {
|
|
23
|
-
return { ok: false, error: 'local model endpoint is missing `url`', refusedRemote: false };
|
|
24
|
-
}
|
|
25
26
|
const model = typeof endpoint.model === 'string' ? endpoint.model.trim() : '';
|
|
26
|
-
if (!model) {
|
|
27
|
-
return { ok: false, error: 'local model endpoint is missing `model`', refusedRemote: false };
|
|
28
|
-
}
|
|
29
27
|
const apiKey = typeof endpoint.apiKey === 'string' && endpoint.apiKey.length > 0 ? endpoint.apiKey : null;
|
|
30
28
|
const loopback = isLoopbackUrl(rawUrl);
|
|
31
29
|
if (!loopback && !allowRemote) {
|