etymd 0.7.0 → 0.9.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/CHANGELOG.md +26 -0
- package/dist/{approve-TXF3MA3G.js → approve-H4UMLKRJ.js} +2 -2
- package/dist/audit-22SOGKLY.js +12 -0
- package/dist/{brief-QUYHRWH6.js → brief-VKNKPZJF.js} +2 -2
- package/dist/{chunk-LXEYYITD.js → chunk-5P7KZV4W.js} +390 -286
- package/dist/{chunk-K7QBTANO.js → chunk-D2RI3CCT.js} +4 -3
- package/dist/{chunk-OGIHOTLP.js → chunk-E3CVCK5X.js} +1 -1
- package/dist/{chunk-OKU3HXIH.js → chunk-JSUYV26K.js} +1 -1
- package/dist/{chunk-XJMN2HMH.js → chunk-MUNRU2LF.js} +1 -1
- package/dist/{chunk-KTIEYKFK.js → chunk-WENIJMVV.js} +20 -2
- package/dist/{chunk-7LVCGCHB.js → chunk-ZA7GVR63.js} +1 -1
- package/dist/cli.js +14 -14
- package/dist/{config-UJRSCDMD.js → config-7WCVZCM6.js} +1 -1
- package/dist/{context-ZEUI5ESZ.js → context-5ANOP2IJ.js} +2 -2
- package/dist/{doctor-2BU6WCI6.js → doctor-ITR74ZUE.js} +6 -6
- package/dist/{fleet-3UQ65X4Y.js → fleet-CTBMXRHV.js} +8 -8
- package/dist/{gates-MYNOZCON.js → gates-X7WY7IRK.js} +38 -9
- package/dist/generate-IPY5QIR5.js +6 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.js +414 -289
- package/dist/{init-52PR24Q4.js → init-ROJ6LQX3.js} +4 -4
- package/dist/{scan-GSDICQCX.js → scan-2ODW52EK.js} +2 -2
- package/dist/scan-VL4DW5ZT.js +5 -0
- package/package.json +1 -1
- package/dist/audit-354X7QN6.js +0 -12
- package/dist/generate-UTKQ4GE6.js +0 -6
- package/dist/scan-M6RKDNYR.js +0 -5
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { measureContext } from './chunk-
|
|
2
|
+
import { measureContext } from './chunk-JSUYV26K.js';
|
|
3
3
|
import { readLedger, reconcileLedger, writeLedger, visibleFindings } from './chunk-OAFYLBVG.js';
|
|
4
|
-
import { DEFAULT_CONFIG, readConfig, CONFIG_FILE } from './chunk-
|
|
5
|
-
import { scanProject, expandFileGlobs } from './chunk-
|
|
4
|
+
import { DEFAULT_CONFIG, readConfig, CONFIG_FILE } from './chunk-D2RI3CCT.js';
|
|
5
|
+
import { scanProject, expandFileGlobs } from './chunk-MUNRU2LF.js';
|
|
6
6
|
import { ETYMD_DIR, writeCachedFacts, readBaseline, deriveProfile, baselineCarriesMachinePath, BASELINE_FILE } from './chunk-C7LBUFNU.js';
|
|
7
7
|
import { PACK_VERSION } from './chunk-6DUDIVIC.js';
|
|
8
|
-
import { pathExists, readJson,
|
|
8
|
+
import { pathExists, readJson, readText, isDirectory, matchesAnyGlob, git, normalizeRelPath, isCiEnvironment, isExecutable } from './chunk-IV3FYVTS.js';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import YAML from 'yaml';
|
|
11
11
|
import { promises } from 'node:fs';
|
|
@@ -667,6 +667,281 @@ var gateIntegrityLens = {
|
|
|
667
667
|
};
|
|
668
668
|
}
|
|
669
669
|
};
|
|
670
|
+
var LENS_ID3 = "state-freshness";
|
|
671
|
+
var DECISIONS_FORMAT_MARKER = "<!-- decisions-format: 1 -->";
|
|
672
|
+
var KNOWN_FORMAT_VERSION = 1;
|
|
673
|
+
var MARKER_RE = /<!--\s*decisions-format:\s*(\d+)([^>]*?)-->/;
|
|
674
|
+
var FIELD_NAME_RE = /^[A-Za-z0-9 _-]+$/;
|
|
675
|
+
var BUILT_IN_FIELDS = /* @__PURE__ */ new Set(["scope"]);
|
|
676
|
+
var MS_PER_DAY = 864e5;
|
|
677
|
+
function parseDecisionsFormat(text) {
|
|
678
|
+
const m = MARKER_RE.exec(text);
|
|
679
|
+
if (!m) return null;
|
|
680
|
+
const problems = [];
|
|
681
|
+
const fields = [];
|
|
682
|
+
const offset = m.index ?? 0;
|
|
683
|
+
const version = Number(m[1]);
|
|
684
|
+
if (version !== KNOWN_FORMAT_VERSION) {
|
|
685
|
+
problems.push(
|
|
686
|
+
`declares decisions-format version ${version}; this etymd understands version ${KNOWN_FORMAT_VERSION} \u2014 checked as version ${KNOWN_FORMAT_VERSION}.`
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
const attrs = (m[2] ?? "").trim();
|
|
690
|
+
if (!attrs) return { fields, offset, problems };
|
|
691
|
+
const declared = /^fields=(.*)$/.exec(attrs);
|
|
692
|
+
if (!declared) {
|
|
693
|
+
problems.push(`marker attribute \`${attrs}\` is not understood \u2014 ignored (only \`fields=\`).`);
|
|
694
|
+
return { fields, offset, problems };
|
|
695
|
+
}
|
|
696
|
+
const seen = new Set(BUILT_IN_FIELDS);
|
|
697
|
+
for (const raw of declared[1].split(",")) {
|
|
698
|
+
const name = raw.trim();
|
|
699
|
+
if (!name) continue;
|
|
700
|
+
if (!FIELD_NAME_RE.test(name)) {
|
|
701
|
+
problems.push(
|
|
702
|
+
`declared field \`${name}\` is not a usable field name (letters, digits, spaces, \`-\`, \`_\`) \u2014 not checked.`
|
|
703
|
+
);
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
const key = name.toLowerCase();
|
|
707
|
+
if (seen.has(key)) continue;
|
|
708
|
+
seen.add(key);
|
|
709
|
+
fields.push(name);
|
|
710
|
+
}
|
|
711
|
+
if (fields.length === 0 && problems.length === 0) {
|
|
712
|
+
problems.push("marker declares `fields=` with no field names \u2014 no extra fields checked.");
|
|
713
|
+
}
|
|
714
|
+
return { fields, offset, problems };
|
|
715
|
+
}
|
|
716
|
+
function hasField(block, name) {
|
|
717
|
+
return new RegExp(`${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s*]*:`).test(block);
|
|
718
|
+
}
|
|
719
|
+
function parseDecisionEntries(text) {
|
|
720
|
+
const headings = [...text.matchAll(/^## .*$/gm)];
|
|
721
|
+
const entries = [];
|
|
722
|
+
for (let i = 0; i < headings.length; i++) {
|
|
723
|
+
const h = headings[i];
|
|
724
|
+
const m = /^## (D-(\d+))\b/.exec(h[0]);
|
|
725
|
+
if (!m) continue;
|
|
726
|
+
const offset = h.index ?? 0;
|
|
727
|
+
const start = offset + h[0].length;
|
|
728
|
+
const end = i + 1 < headings.length ? headings[i + 1].index : void 0;
|
|
729
|
+
entries.push({ id: m[1], num: Number(m[2]), block: text.slice(start, end), offset });
|
|
730
|
+
}
|
|
731
|
+
return entries;
|
|
732
|
+
}
|
|
733
|
+
function checkIdSequence(file, entries) {
|
|
734
|
+
const findings = [];
|
|
735
|
+
const nextFree = Math.max(0, ...entries.map((e) => e.num)) + 1;
|
|
736
|
+
const seen = /* @__PURE__ */ new Map();
|
|
737
|
+
const duplicated = /* @__PURE__ */ new Set();
|
|
738
|
+
let prev;
|
|
739
|
+
for (const entry of entries) {
|
|
740
|
+
if (seen.has(entry.num)) {
|
|
741
|
+
if (!duplicated.has(entry.num)) {
|
|
742
|
+
duplicated.add(entry.num);
|
|
743
|
+
findings.push({
|
|
744
|
+
id: `${LENS_ID3}/duplicate-id:${file}:${entry.id}`,
|
|
745
|
+
lens: LENS_ID3,
|
|
746
|
+
tier: "gap",
|
|
747
|
+
claim: `${file} carries more than one ${entry.id} entry`,
|
|
748
|
+
evidence: [`${file}: ${entry.id} appears twice`],
|
|
749
|
+
why: "Two decisions under one id cannot be cited, superseded, or dismissed unambiguously \u2014 append races collide exactly here.",
|
|
750
|
+
action: `Rename the later entry to D-${String(nextFree).padStart(3, "0")} (the next free id).`,
|
|
751
|
+
effort: "S",
|
|
752
|
+
confidence: "high"
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
} else {
|
|
756
|
+
seen.set(entry.num, entry);
|
|
757
|
+
if (prev && entry.num < prev.num) {
|
|
758
|
+
findings.push({
|
|
759
|
+
id: `${LENS_ID3}/id-order:${file}:${entry.id}`,
|
|
760
|
+
lens: LENS_ID3,
|
|
761
|
+
tier: "gap",
|
|
762
|
+
claim: `${file} lists ${entry.id} after ${prev.id} \u2014 ids out of append order`,
|
|
763
|
+
evidence: [`${file}: ${prev.id} precedes ${entry.id}`],
|
|
764
|
+
why: "An append-only record reads in id order; out-of-order ids make the newest decision hard to find and the next id hard to pick.",
|
|
765
|
+
action: `Rename the out-of-order entry into sequence (next free id: D-${String(nextFree).padStart(3, "0")}).`,
|
|
766
|
+
effort: "S",
|
|
767
|
+
confidence: "high"
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
prev = entry;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return findings;
|
|
774
|
+
}
|
|
775
|
+
function checkFormatFields(file, entries, today, declaredFields, markerOffset) {
|
|
776
|
+
const findings = [];
|
|
777
|
+
for (const entry of entries) {
|
|
778
|
+
const bound = entry.offset >= markerOffset;
|
|
779
|
+
for (const field of bound ? declaredFields : []) {
|
|
780
|
+
if (hasField(entry.block, field)) continue;
|
|
781
|
+
findings.push({
|
|
782
|
+
id: `${LENS_ID3}/field-missing:${file}:${entry.id}:${field}`,
|
|
783
|
+
lens: LENS_ID3,
|
|
784
|
+
tier: "gap",
|
|
785
|
+
claim: `${file} ${entry.id} has no ${field}: field`,
|
|
786
|
+
evidence: [`${file}: ${entry.id}`, `${file} marker declares required field \`${field}\``],
|
|
787
|
+
why: "The file declares this field required on every entry after the marker; whatever reads the record for it finds nothing here.",
|
|
788
|
+
action: `Add a ${field}: line to ${entry.id}.`,
|
|
789
|
+
effort: "S",
|
|
790
|
+
confidence: "high"
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
if (bound && !/Scope[\s*]*:/.test(entry.block)) {
|
|
794
|
+
findings.push({
|
|
795
|
+
id: `${LENS_ID3}/scope-missing:${file}:${entry.id}`,
|
|
796
|
+
lens: LENS_ID3,
|
|
797
|
+
tier: "gap",
|
|
798
|
+
claim: `${file} ${entry.id} has no Scope: field`,
|
|
799
|
+
evidence: [`${file}: ${entry.id}`],
|
|
800
|
+
why: "A decision without a scope binds nobody \u2014 a reader cannot tell whether it covers the project they are working in.",
|
|
801
|
+
action: "Add a Scope: line naming what the decision binds.",
|
|
802
|
+
effort: "S",
|
|
803
|
+
confidence: "high"
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
const revisit = /Revisit[\s*]*:[\s*]*(\d{4}-\d{2}-\d{2})/.exec(entry.block);
|
|
807
|
+
if (revisit && revisit[1] < today) {
|
|
808
|
+
findings.push({
|
|
809
|
+
id: `${LENS_ID3}/revisit-due:${file}:${entry.id}`,
|
|
810
|
+
lens: LENS_ID3,
|
|
811
|
+
tier: "gap",
|
|
812
|
+
claim: `${file} ${entry.id} was due for revisit on ${revisit[1]}`,
|
|
813
|
+
evidence: [`${file}: ${entry.id} Revisit: ${revisit[1]}`],
|
|
814
|
+
why: "Review debt is due \u2014 a Revisit date is a promise to re-evaluate, and a past one silently hardens into policy.",
|
|
815
|
+
action: "Re-evaluate the decision: supersede it or move the Revisit date.",
|
|
816
|
+
effort: "S",
|
|
817
|
+
confidence: "high"
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return findings;
|
|
822
|
+
}
|
|
823
|
+
var stateFreshnessLens = {
|
|
824
|
+
id: LENS_ID3,
|
|
825
|
+
version: "1",
|
|
826
|
+
title: "State freshness",
|
|
827
|
+
kind: "truth",
|
|
828
|
+
async run(ctx) {
|
|
829
|
+
const budgets = ctx.config?.config.state ?? DEFAULT_CONFIG.state;
|
|
830
|
+
const findings = [];
|
|
831
|
+
const disclosures = [...ctx.config?.problems ?? []];
|
|
832
|
+
const outOfScope = [];
|
|
833
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
834
|
+
const stateArtifacts = ctx.facts.artifacts.filter((a) => a.kind === "state" && a.exists);
|
|
835
|
+
const decisionArtifacts = ctx.facts.artifacts.filter((a) => a.kind === "decisions" && a.exists);
|
|
836
|
+
if (stateArtifacts.length === 0 && decisionArtifacts.length === 0) {
|
|
837
|
+
disclosures.push("No state or decisions artifacts detected \u2014 nothing to check.");
|
|
838
|
+
}
|
|
839
|
+
const freshness = ctx.facts.freshness;
|
|
840
|
+
if (!freshness) {
|
|
841
|
+
disclosures.push("Scan carried no freshness facts \u2014 staleness unchecked.");
|
|
842
|
+
} else {
|
|
843
|
+
for (const u of freshness.unverifiable) {
|
|
844
|
+
disclosures.push(`Freshness of ${u.path} is unverifiable (${u.reason}) \u2014 not flagged.`);
|
|
845
|
+
}
|
|
846
|
+
for (const a of stateArtifacts) {
|
|
847
|
+
const fact = freshness.artifacts.find((f) => f.artifactId === a.id);
|
|
848
|
+
if (!fact || !freshness.repoLastCommit) continue;
|
|
849
|
+
if (fact.dirty) {
|
|
850
|
+
disclosures.push(
|
|
851
|
+
`${a.path} has uncommitted changes \u2014 modified since its last commit; treated fresh-now, not flagged.`
|
|
852
|
+
);
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
if (!fact.commitsSince) continue;
|
|
856
|
+
const gapDays = Math.floor(
|
|
857
|
+
(Date.parse(freshness.repoLastCommit) - Date.parse(fact.lastCommit)) / MS_PER_DAY
|
|
858
|
+
);
|
|
859
|
+
if (gapDays <= budgets.staleAfterDays) continue;
|
|
860
|
+
const escalated = gapDays > budgets.staleAfterDays * 3;
|
|
861
|
+
findings.push({
|
|
862
|
+
id: `${LENS_ID3}/stale-state:${a.path}`,
|
|
863
|
+
lens: LENS_ID3,
|
|
864
|
+
tier: escalated ? "risk" : "gap",
|
|
865
|
+
claim: `${a.path} trails the repo by ${gapDays} days of commit traffic \u2014 it says "now" but the repo moved on`,
|
|
866
|
+
evidence: [
|
|
867
|
+
`${a.path} last commit: ${fact.lastCommit}`,
|
|
868
|
+
`repo last commit: ${freshness.repoLastCommit}`
|
|
869
|
+
],
|
|
870
|
+
why: escalated ? `Over three times the ${budgets.staleAfterDays}-day threshold while commits kept landing \u2014 every session starts from a picture of the project that is no longer true.` : `A state doc more than ${budgets.staleAfterDays} days behind continued commit traffic misleads every session that loads it.`,
|
|
871
|
+
action: "Refresh the state doc (or record why it is still current).",
|
|
872
|
+
effort: "S",
|
|
873
|
+
confidence: "high"
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
for (const a of stateArtifacts) {
|
|
878
|
+
const text = await readText(path.join(ctx.root, a.path));
|
|
879
|
+
if (text === null) {
|
|
880
|
+
disclosures.push(`${a.path} could not be read \u2014 unexamined, not clean.`);
|
|
881
|
+
outOfScope.push(a.path);
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
if (text.length > budgets.maxChars) {
|
|
885
|
+
findings.push({
|
|
886
|
+
id: `${LENS_ID3}/state-over-budget:${a.path}`,
|
|
887
|
+
lens: LENS_ID3,
|
|
888
|
+
tier: "gap",
|
|
889
|
+
claim: `${a.path} is ${text.length} chars \u2014 over the ${budgets.maxChars}-char state budget`,
|
|
890
|
+
evidence: [`${a.path}: ${text.length} chars`],
|
|
891
|
+
why: "Session-injection hooks truncate state around 10,000 chars \u2014 an over-budget state doc gets cut mid-sentence, and every session pays its full weight before the task begins.",
|
|
892
|
+
action: "Trim to budget; move overflow into decisions/docs and keep a pointer.",
|
|
893
|
+
effort: "M",
|
|
894
|
+
confidence: "high"
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
for (const a of decisionArtifacts) {
|
|
899
|
+
const text = await readText(path.join(ctx.root, a.path));
|
|
900
|
+
if (text === null) {
|
|
901
|
+
disclosures.push(
|
|
902
|
+
`${a.path} recognized as a decisions convention (directory) \u2014 age-exempt; per-entry format checks apply only to marker-carrying decisions files.`
|
|
903
|
+
);
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
const entries = parseDecisionEntries(text);
|
|
907
|
+
findings.push(...checkIdSequence(a.path, entries));
|
|
908
|
+
const format = parseDecisionsFormat(text);
|
|
909
|
+
if (!format) {
|
|
910
|
+
disclosures.push(
|
|
911
|
+
`${a.path} carries no \`${DECISIONS_FORMAT_MARKER}\` marker \u2014 format checks skipped (forward-only, never retroactive); id-sequence checks still ran.`
|
|
912
|
+
);
|
|
913
|
+
outOfScope.push(a.path);
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
for (const problem of format.problems) disclosures.push(`${a.path}: ${problem}`);
|
|
917
|
+
const exempt = entries.filter((e) => e.offset < format.offset);
|
|
918
|
+
if (format.fields.length > 0) {
|
|
919
|
+
disclosures.push(
|
|
920
|
+
`${a.path} declares required entry fields: ${format.fields.join(", ")} \u2014 checked on every entry at or after the marker (etymd attaches no meaning to the names).`
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
if (exempt.length > 0) {
|
|
924
|
+
disclosures.push(
|
|
925
|
+
`${a.path}: ${exempt.length} entr${exempt.length === 1 ? "y" : "ies"} precede the format marker (${exempt[0]?.id}\u2026${exempt[exempt.length - 1]?.id}) \u2014 field presence not checked there (forward-only from the marker's position).`
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
findings.push(...checkFormatFields(a.path, entries, today, format.fields, format.offset));
|
|
929
|
+
}
|
|
930
|
+
disclosures.push(
|
|
931
|
+
`Thresholds: staleAfterDays ${budgets.staleAfterDays} (3x escalates to risk), state budget ${budgets.maxChars} chars (${budgets.staleAfterDays === DEFAULT_CONFIG.state.staleAfterDays && budgets.maxChars === DEFAULT_CONFIG.state.maxChars ? `defaults \u2014 override under \`state\` in ${CONFIG_FILE}` : `set in ${CONFIG_FILE}`}). Decisions artifacts are exempt from age \u2014 old decisions are history, not defects.`
|
|
932
|
+
);
|
|
933
|
+
return {
|
|
934
|
+
lens: LENS_ID3,
|
|
935
|
+
version: "1",
|
|
936
|
+
title: "State freshness",
|
|
937
|
+
kind: "truth",
|
|
938
|
+
status: "ran",
|
|
939
|
+
disclosures,
|
|
940
|
+
findings,
|
|
941
|
+
...outOfScope.length ? { outOfScope } : {}
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
};
|
|
670
945
|
async function listInstructionFiles(root, facts, scope) {
|
|
671
946
|
const files = [];
|
|
672
947
|
const add = async (rel) => {
|
|
@@ -724,6 +999,15 @@ async function listInstructionFiles(root, facts, scope) {
|
|
|
724
999
|
}
|
|
725
1000
|
return { files: kept, excluded, included };
|
|
726
1001
|
}
|
|
1002
|
+
async function listStateDocuments(root, facts) {
|
|
1003
|
+
const docs = [];
|
|
1004
|
+
for (const artifact of facts.artifacts) {
|
|
1005
|
+
if (artifact.kind !== "state" || !artifact.exists) continue;
|
|
1006
|
+
const text = await readText(path.join(root, artifact.path));
|
|
1007
|
+
if (text !== null) docs.push({ path: normalizeRelPath(artifact.path), text });
|
|
1008
|
+
}
|
|
1009
|
+
return docs;
|
|
1010
|
+
}
|
|
727
1011
|
function extractCodeTokens(text) {
|
|
728
1012
|
const tokens = [];
|
|
729
1013
|
for (const m of text.matchAll(/`([^`\n]+)`/g)) tokens.push(m[1].trim());
|
|
@@ -861,6 +1145,30 @@ function extractPathClaims(text) {
|
|
|
861
1145
|
}
|
|
862
1146
|
return { paths, prospective, placeholder: [...placeholder] };
|
|
863
1147
|
}
|
|
1148
|
+
var LOCAL_REF_LEADINS = new Set(
|
|
1149
|
+
"decision decisions entry entries ruling rulings record records ledger id ids item items see per in of on at by to as is was are were the a an and or but not with under over from via vs than after before since between through against latest newest earliest only also still now supersedes superseded superseding amends amended extends extended cites cited citing adds added adding wrote written writes locked locks closed closes opened opens resolves resolved reopened recorded number numbers".split(" ")
|
|
1150
|
+
);
|
|
1151
|
+
function extractDecisionRefs(text) {
|
|
1152
|
+
const byNum = /* @__PURE__ */ new Map();
|
|
1153
|
+
for (const m of text.matchAll(/\bD-(\d{1,4})\b/g)) {
|
|
1154
|
+
const index = m.index ?? 0;
|
|
1155
|
+
if (index > 0 && /[-/_.]/.test(text[index - 1])) continue;
|
|
1156
|
+
const before = text.slice(Math.max(0, index - 48), index);
|
|
1157
|
+
const lead = /([A-Za-z][A-Za-z0-9'’-]*)[ \t]+$/.exec(before)?.[1];
|
|
1158
|
+
const local = !lead || LOCAL_REF_LEADINS.has(lead.toLowerCase());
|
|
1159
|
+
const num = Number(m[1]);
|
|
1160
|
+
const seen = byNum.get(num);
|
|
1161
|
+
if (!seen) byNum.set(num, { asWritten: m[0], local });
|
|
1162
|
+
else seen.local = seen.local || local;
|
|
1163
|
+
}
|
|
1164
|
+
const refs = /* @__PURE__ */ new Map();
|
|
1165
|
+
let qualifiedSkipped = 0;
|
|
1166
|
+
for (const [num, ref] of byNum) {
|
|
1167
|
+
if (ref.local) refs.set(num, ref.asWritten);
|
|
1168
|
+
else qualifiedSkipped += 1;
|
|
1169
|
+
}
|
|
1170
|
+
return { refs, qualifiedSkipped };
|
|
1171
|
+
}
|
|
864
1172
|
function packageManagerUsage(text) {
|
|
865
1173
|
const counts = /* @__PURE__ */ new Map();
|
|
866
1174
|
for (const token of extractCodeTokens(text)) {
|
|
@@ -883,10 +1191,10 @@ function extractDocRefs(text) {
|
|
|
883
1191
|
}
|
|
884
1192
|
|
|
885
1193
|
// src/lenses/instruction-truth/lens.ts
|
|
886
|
-
var
|
|
1194
|
+
var LENS_ID4 = "instruction-truth";
|
|
887
1195
|
var MAX_PATH_FINDINGS_PER_FILE = 15;
|
|
888
1196
|
function finding2(partial) {
|
|
889
|
-
return { lens:
|
|
1197
|
+
return { lens: LENS_ID4, ...partial };
|
|
890
1198
|
}
|
|
891
1199
|
function compareCommands(baseline, fresh) {
|
|
892
1200
|
const out = [];
|
|
@@ -895,7 +1203,7 @@ function compareCommands(baseline, fresh) {
|
|
|
895
1203
|
if (before && !(before in fresh.commands.raw)) {
|
|
896
1204
|
out.push(
|
|
897
1205
|
finding2({
|
|
898
|
-
id: `${
|
|
1206
|
+
id: `${LENS_ID4}/command-gone-${role}`,
|
|
899
1207
|
tier: "risk",
|
|
900
1208
|
claim: `Documented ${role} command \`${before}\` no longer exists in package.json`,
|
|
901
1209
|
evidence: ["package.json"],
|
|
@@ -917,7 +1225,7 @@ function compareArtifacts(baseline, fresh) {
|
|
|
917
1225
|
if (a.exists && now && !now.exists) {
|
|
918
1226
|
out.push(
|
|
919
1227
|
finding2({
|
|
920
|
-
id: `${
|
|
1228
|
+
id: `${LENS_ID4}/artifact-gone-${a.id}`,
|
|
921
1229
|
tier: "gap",
|
|
922
1230
|
claim: `${a.label} was present at baseline but is now missing`,
|
|
923
1231
|
evidence: [a.path],
|
|
@@ -935,7 +1243,7 @@ function compareLayout(baseline, fresh) {
|
|
|
935
1243
|
const now = new Set(fresh.tree.dirs.map((d) => d.name));
|
|
936
1244
|
return baseline.tree.dirs.filter((d) => !now.has(d.name)).map(
|
|
937
1245
|
(d) => finding2({
|
|
938
|
-
id: `${
|
|
1246
|
+
id: `${LENS_ID4}/dir-gone-${d.name}`,
|
|
939
1247
|
tier: "gap",
|
|
940
1248
|
claim: `Top-level \`${d.name}/\` from the baseline no longer exists \u2014 the repo map may be stale`,
|
|
941
1249
|
evidence: [`${d.name}/`],
|
|
@@ -947,7 +1255,7 @@ function compareLayout(baseline, fresh) {
|
|
|
947
1255
|
);
|
|
948
1256
|
}
|
|
949
1257
|
var instructionTruthLens = {
|
|
950
|
-
id:
|
|
1258
|
+
id: LENS_ID4,
|
|
951
1259
|
version: "1",
|
|
952
1260
|
title: "Instruction truth",
|
|
953
1261
|
kind: "truth",
|
|
@@ -965,7 +1273,7 @@ var instructionTruthLens = {
|
|
|
965
1273
|
if (!files.length) {
|
|
966
1274
|
findings.push(
|
|
967
1275
|
finding2({
|
|
968
|
-
id: `${
|
|
1276
|
+
id: `${LENS_ID4}/no-contract`,
|
|
969
1277
|
tier: "gap",
|
|
970
1278
|
claim: "No agent instruction files exist (AGENTS.md or equivalents)",
|
|
971
1279
|
evidence: ["AGENTS.md (missing)"],
|
|
@@ -1000,13 +1308,14 @@ var instructionTruthLens = {
|
|
|
1000
1308
|
return false;
|
|
1001
1309
|
};
|
|
1002
1310
|
const nodeModulesInstalled = await pathExists(path.join(root, "node_modules"));
|
|
1311
|
+
const manifestExists = await pathExists(path.join(root, "package.json")) || facts.packages.length > 0;
|
|
1003
1312
|
let totalFilteredSkipped = 0;
|
|
1004
1313
|
let binaryResolved = 0;
|
|
1005
1314
|
let unverifiableCommands = 0;
|
|
1006
1315
|
let gitignoredSkipped = 0;
|
|
1007
1316
|
let prospectiveSkipped = 0;
|
|
1008
1317
|
let placeholderSkipped = 0;
|
|
1009
|
-
|
|
1318
|
+
const auditClaims = async (file) => {
|
|
1010
1319
|
const { scripts: claimed, filteredSkipped } = extractCommandClaims(file.text);
|
|
1011
1320
|
totalFilteredSkipped += filteredSkipped;
|
|
1012
1321
|
for (const [script, raw] of claimed) {
|
|
@@ -1015,13 +1324,13 @@ var instructionTruthLens = {
|
|
|
1015
1324
|
binaryResolved += 1;
|
|
1016
1325
|
continue;
|
|
1017
1326
|
}
|
|
1018
|
-
if (!nodeModulesInstalled) {
|
|
1327
|
+
if (!nodeModulesInstalled && manifestExists) {
|
|
1019
1328
|
unverifiableCommands += 1;
|
|
1020
1329
|
continue;
|
|
1021
1330
|
}
|
|
1022
1331
|
findings.push(
|
|
1023
1332
|
finding2({
|
|
1024
|
-
id: `${
|
|
1333
|
+
id: `${LENS_ID4}/stale-command:${file.path}:${script}`,
|
|
1025
1334
|
tier: "risk",
|
|
1026
1335
|
claim: `${file.path} tells agents to run \`${script}\` \u2014 no such script exists`,
|
|
1027
1336
|
evidence: [`${file.path}: \`${raw}\``, "package.json scripts (root + workspaces)"],
|
|
@@ -1056,7 +1365,7 @@ var instructionTruthLens = {
|
|
|
1056
1365
|
pathFindings += 1;
|
|
1057
1366
|
findings.push(
|
|
1058
1367
|
finding2({
|
|
1059
|
-
id: `${
|
|
1368
|
+
id: `${LENS_ID4}/stale-path:${file.path}:${claim}`,
|
|
1060
1369
|
tier: "gap",
|
|
1061
1370
|
claim: `${file.path} references \`${claim}\` \u2014 it does not exist in the repo`,
|
|
1062
1371
|
evidence: [file.path, `missing: ${claim}`],
|
|
@@ -1067,6 +1376,9 @@ var instructionTruthLens = {
|
|
|
1067
1376
|
})
|
|
1068
1377
|
);
|
|
1069
1378
|
}
|
|
1379
|
+
};
|
|
1380
|
+
for (const file of files) {
|
|
1381
|
+
await auditClaims(file);
|
|
1070
1382
|
if (facts.packageManager !== "unknown") {
|
|
1071
1383
|
const usage = packageManagerUsage(file.text);
|
|
1072
1384
|
const own = usage.get(facts.packageManager) ?? 0;
|
|
@@ -1074,7 +1386,7 @@ var instructionTruthLens = {
|
|
|
1074
1386
|
if (pm === facts.packageManager || count < 2 || count <= own) continue;
|
|
1075
1387
|
findings.push(
|
|
1076
1388
|
finding2({
|
|
1077
|
-
id: `${
|
|
1389
|
+
id: `${LENS_ID4}/pm-conflict:${file.path}`,
|
|
1078
1390
|
tier: "gap",
|
|
1079
1391
|
claim: `${file.path} instructs \`${pm}\` (${count}\xD7) but the repo uses ${facts.packageManager}`,
|
|
1080
1392
|
evidence: [file.path, `lockfile \u2192 ${facts.packageManager}`],
|
|
@@ -1091,7 +1403,7 @@ var instructionTruthLens = {
|
|
|
1091
1403
|
if (await pathExists(path.join(root, ref))) continue;
|
|
1092
1404
|
findings.push(
|
|
1093
1405
|
finding2({
|
|
1094
|
-
id: `${
|
|
1406
|
+
id: `${LENS_ID4}/dangling-ref:${file.path}:${ref}`,
|
|
1095
1407
|
tier: "gap",
|
|
1096
1408
|
claim: `${file.path} references ${ref} \u2014 no such file exists`,
|
|
1097
1409
|
evidence: [file.path, `missing: ${ref}`],
|
|
@@ -1103,8 +1415,51 @@ var instructionTruthLens = {
|
|
|
1103
1415
|
);
|
|
1104
1416
|
}
|
|
1105
1417
|
}
|
|
1106
|
-
|
|
1107
|
-
|
|
1418
|
+
const auditedPaths = new Set(files.map((f) => f.path));
|
|
1419
|
+
const stateDocs = await listStateDocuments(root, facts);
|
|
1420
|
+
let qualifiedRefsSkipped = 0;
|
|
1421
|
+
let unresolvableRefs = 0;
|
|
1422
|
+
let ledgerIds = null;
|
|
1423
|
+
const ledgerSources = [];
|
|
1424
|
+
if (stateDocs.length) {
|
|
1425
|
+
for (const artifact of facts.artifacts) {
|
|
1426
|
+
if (artifact.kind !== "decisions" || !artifact.exists) continue;
|
|
1427
|
+
const text = await readText(path.join(root, artifact.path));
|
|
1428
|
+
if (text === null) continue;
|
|
1429
|
+
const entries = parseDecisionEntries(text);
|
|
1430
|
+
if (!entries.length) continue;
|
|
1431
|
+
ledgerIds ??= /* @__PURE__ */ new Set();
|
|
1432
|
+
for (const entry of entries) ledgerIds.add(entry.num);
|
|
1433
|
+
ledgerSources.push(artifact.path);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
for (const doc of stateDocs) {
|
|
1437
|
+
if (!auditedPaths.has(doc.path)) await auditClaims(doc);
|
|
1438
|
+
const { refs, qualifiedSkipped } = extractDecisionRefs(doc.text);
|
|
1439
|
+
qualifiedRefsSkipped += qualifiedSkipped;
|
|
1440
|
+
if (!refs.size) continue;
|
|
1441
|
+
if (!ledgerIds) {
|
|
1442
|
+
unresolvableRefs += refs.size;
|
|
1443
|
+
continue;
|
|
1444
|
+
}
|
|
1445
|
+
for (const [num, asWritten] of refs) {
|
|
1446
|
+
if (ledgerIds.has(num)) continue;
|
|
1447
|
+
findings.push(
|
|
1448
|
+
finding2({
|
|
1449
|
+
id: `${LENS_ID4}/dead-decision-ref:${doc.path}:${asWritten}`,
|
|
1450
|
+
tier: "gap",
|
|
1451
|
+
claim: `${doc.path} cites ${asWritten} \u2014 no such entry exists in ${ledgerSources.join(", ")}`,
|
|
1452
|
+
evidence: [doc.path, `${ledgerSources.join(", ")}: no ${asWritten} entry`],
|
|
1453
|
+
why: "A state doc is read as ground truth on return; a citation the decision record cannot back sends readers to a ruling that was never written.",
|
|
1454
|
+
action: "Fix the reference \u2014 or record the missing decision.",
|
|
1455
|
+
effort: "S",
|
|
1456
|
+
confidence: "medium"
|
|
1457
|
+
})
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
if (ctx.baseline) {
|
|
1462
|
+
findings.push(
|
|
1108
1463
|
...compareCommands(ctx.baseline.facts, facts),
|
|
1109
1464
|
...compareArtifacts(ctx.baseline.facts, facts),
|
|
1110
1465
|
...compareLayout(ctx.baseline.facts, facts)
|
|
@@ -1149,6 +1504,21 @@ var instructionTruthLens = {
|
|
|
1149
1504
|
`${placeholderSkipped} path claim(s) are naming stand-ins (e.g. \`my-custom-skill\`) rather than real references; skipped, not flagged.`
|
|
1150
1505
|
);
|
|
1151
1506
|
}
|
|
1507
|
+
if (stateDocs.length) {
|
|
1508
|
+
disclosures.push(
|
|
1509
|
+
`Checked ${stateDocs.length} state document(s) for command, path, and decision-reference claims (same skip classes as instruction files); decision ids resolved against ${ledgerSources.length ? ledgerSources.join(", ") : "nothing \u2014 no decisions file with D-NNN entries"}.`
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
if (qualifiedRefsSkipped) {
|
|
1513
|
+
disclosures.push(
|
|
1514
|
+
`${qualifiedRefsSkipped} decision reference(s) name another record (e.g. a fleet-level ledger) \u2014 not claims about this repo's decisions; skipped, not flagged.`
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
if (unresolvableRefs) {
|
|
1518
|
+
disclosures.push(
|
|
1519
|
+
`${unresolvableRefs} decision reference(s) could not be resolved \u2014 no decisions file with \`## D-NNN\` entries; skipped, not flagged.`
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
1152
1522
|
if (excluded.length) {
|
|
1153
1523
|
const shown = excluded.slice(0, 5).join(", ");
|
|
1154
1524
|
disclosures.push(
|
|
@@ -1164,7 +1534,7 @@ var instructionTruthLens = {
|
|
|
1164
1534
|
`Checked ${files.length} instruction file(s); commands resolved against root + ${facts.packages.length} workspace manifest(s) plus installed binaries; paths matched against root and package roots. Heuristics: workspace-filtered commands skipped (${totalFilteredSkipped}); tokens without a recognized extension treated as prose (a dir claim needs a trailing slash); gitignored claims unverifiable; create-this and stand-in path claims skipped; absolute/globbed/placeholder tokens skipped; framework-pattern staleness not checked.`
|
|
1165
1535
|
);
|
|
1166
1536
|
return {
|
|
1167
|
-
lens:
|
|
1537
|
+
lens: LENS_ID4,
|
|
1168
1538
|
version: "1",
|
|
1169
1539
|
title: "Instruction truth",
|
|
1170
1540
|
kind: "truth",
|
|
@@ -1175,272 +1545,6 @@ var instructionTruthLens = {
|
|
|
1175
1545
|
};
|
|
1176
1546
|
}
|
|
1177
1547
|
};
|
|
1178
|
-
var LENS_ID4 = "state-freshness";
|
|
1179
|
-
var DECISIONS_FORMAT_MARKER = "<!-- decisions-format: 1 -->";
|
|
1180
|
-
var KNOWN_FORMAT_VERSION = 1;
|
|
1181
|
-
var MARKER_RE = /<!--\s*decisions-format:\s*(\d+)([^>]*?)-->/;
|
|
1182
|
-
var FIELD_NAME_RE = /^[A-Za-z0-9 _-]+$/;
|
|
1183
|
-
var BUILT_IN_FIELDS = /* @__PURE__ */ new Set(["scope"]);
|
|
1184
|
-
var MS_PER_DAY = 864e5;
|
|
1185
|
-
function parseDecisionsFormat(text) {
|
|
1186
|
-
const m = MARKER_RE.exec(text);
|
|
1187
|
-
if (!m) return null;
|
|
1188
|
-
const problems = [];
|
|
1189
|
-
const fields = [];
|
|
1190
|
-
const version = Number(m[1]);
|
|
1191
|
-
if (version !== KNOWN_FORMAT_VERSION) {
|
|
1192
|
-
problems.push(
|
|
1193
|
-
`declares decisions-format version ${version}; this etymd understands version ${KNOWN_FORMAT_VERSION} \u2014 checked as version ${KNOWN_FORMAT_VERSION}.`
|
|
1194
|
-
);
|
|
1195
|
-
}
|
|
1196
|
-
const attrs = (m[2] ?? "").trim();
|
|
1197
|
-
if (!attrs) return { fields, problems };
|
|
1198
|
-
const declared = /^fields=(.*)$/.exec(attrs);
|
|
1199
|
-
if (!declared) {
|
|
1200
|
-
problems.push(`marker attribute \`${attrs}\` is not understood \u2014 ignored (only \`fields=\`).`);
|
|
1201
|
-
return { fields, problems };
|
|
1202
|
-
}
|
|
1203
|
-
const seen = new Set(BUILT_IN_FIELDS);
|
|
1204
|
-
for (const raw of declared[1].split(",")) {
|
|
1205
|
-
const name = raw.trim();
|
|
1206
|
-
if (!name) continue;
|
|
1207
|
-
if (!FIELD_NAME_RE.test(name)) {
|
|
1208
|
-
problems.push(
|
|
1209
|
-
`declared field \`${name}\` is not a usable field name (letters, digits, spaces, \`-\`, \`_\`) \u2014 not checked.`
|
|
1210
|
-
);
|
|
1211
|
-
continue;
|
|
1212
|
-
}
|
|
1213
|
-
const key = name.toLowerCase();
|
|
1214
|
-
if (seen.has(key)) continue;
|
|
1215
|
-
seen.add(key);
|
|
1216
|
-
fields.push(name);
|
|
1217
|
-
}
|
|
1218
|
-
if (fields.length === 0 && problems.length === 0) {
|
|
1219
|
-
problems.push("marker declares `fields=` with no field names \u2014 no extra fields checked.");
|
|
1220
|
-
}
|
|
1221
|
-
return { fields, problems };
|
|
1222
|
-
}
|
|
1223
|
-
function hasField(block, name) {
|
|
1224
|
-
return new RegExp(`${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s*]*:`).test(block);
|
|
1225
|
-
}
|
|
1226
|
-
function parseDecisionEntries(text) {
|
|
1227
|
-
const headings = [...text.matchAll(/^## .*$/gm)];
|
|
1228
|
-
const entries = [];
|
|
1229
|
-
for (let i = 0; i < headings.length; i++) {
|
|
1230
|
-
const h = headings[i];
|
|
1231
|
-
const m = /^## (D-(\d+))\b/.exec(h[0]);
|
|
1232
|
-
if (!m) continue;
|
|
1233
|
-
const start = (h.index ?? 0) + h[0].length;
|
|
1234
|
-
const end = i + 1 < headings.length ? headings[i + 1].index : void 0;
|
|
1235
|
-
entries.push({ id: m[1], num: Number(m[2]), block: text.slice(start, end) });
|
|
1236
|
-
}
|
|
1237
|
-
return entries;
|
|
1238
|
-
}
|
|
1239
|
-
function checkIdSequence(file, entries) {
|
|
1240
|
-
const findings = [];
|
|
1241
|
-
const nextFree = Math.max(0, ...entries.map((e) => e.num)) + 1;
|
|
1242
|
-
const seen = /* @__PURE__ */ new Map();
|
|
1243
|
-
const duplicated = /* @__PURE__ */ new Set();
|
|
1244
|
-
let prev;
|
|
1245
|
-
for (const entry of entries) {
|
|
1246
|
-
if (seen.has(entry.num)) {
|
|
1247
|
-
if (!duplicated.has(entry.num)) {
|
|
1248
|
-
duplicated.add(entry.num);
|
|
1249
|
-
findings.push({
|
|
1250
|
-
id: `${LENS_ID4}/duplicate-id:${file}:${entry.id}`,
|
|
1251
|
-
lens: LENS_ID4,
|
|
1252
|
-
tier: "gap",
|
|
1253
|
-
claim: `${file} carries more than one ${entry.id} entry`,
|
|
1254
|
-
evidence: [`${file}: ${entry.id} appears twice`],
|
|
1255
|
-
why: "Two decisions under one id cannot be cited, superseded, or dismissed unambiguously \u2014 append races collide exactly here.",
|
|
1256
|
-
action: `Rename the later entry to D-${String(nextFree).padStart(3, "0")} (the next free id).`,
|
|
1257
|
-
effort: "S",
|
|
1258
|
-
confidence: "high"
|
|
1259
|
-
});
|
|
1260
|
-
}
|
|
1261
|
-
} else {
|
|
1262
|
-
seen.set(entry.num, entry);
|
|
1263
|
-
if (prev && entry.num < prev.num) {
|
|
1264
|
-
findings.push({
|
|
1265
|
-
id: `${LENS_ID4}/id-order:${file}:${entry.id}`,
|
|
1266
|
-
lens: LENS_ID4,
|
|
1267
|
-
tier: "gap",
|
|
1268
|
-
claim: `${file} lists ${entry.id} after ${prev.id} \u2014 ids out of append order`,
|
|
1269
|
-
evidence: [`${file}: ${prev.id} precedes ${entry.id}`],
|
|
1270
|
-
why: "An append-only record reads in id order; out-of-order ids make the newest decision hard to find and the next id hard to pick.",
|
|
1271
|
-
action: `Rename the out-of-order entry into sequence (next free id: D-${String(nextFree).padStart(3, "0")}).`,
|
|
1272
|
-
effort: "S",
|
|
1273
|
-
confidence: "high"
|
|
1274
|
-
});
|
|
1275
|
-
}
|
|
1276
|
-
prev = entry;
|
|
1277
|
-
}
|
|
1278
|
-
}
|
|
1279
|
-
return findings;
|
|
1280
|
-
}
|
|
1281
|
-
function checkFormatFields(file, entries, today, declaredFields) {
|
|
1282
|
-
const findings = [];
|
|
1283
|
-
for (const entry of entries) {
|
|
1284
|
-
for (const field of declaredFields) {
|
|
1285
|
-
if (hasField(entry.block, field)) continue;
|
|
1286
|
-
findings.push({
|
|
1287
|
-
id: `${LENS_ID4}/field-missing:${file}:${entry.id}:${field}`,
|
|
1288
|
-
lens: LENS_ID4,
|
|
1289
|
-
tier: "gap",
|
|
1290
|
-
claim: `${file} ${entry.id} has no ${field}: field`,
|
|
1291
|
-
evidence: [`${file}: ${entry.id}`, `${file} marker declares required field \`${field}\``],
|
|
1292
|
-
why: "The file declares this field required on every entry after the marker; whatever reads the record for it finds nothing here.",
|
|
1293
|
-
action: `Add a ${field}: line to ${entry.id}.`,
|
|
1294
|
-
effort: "S",
|
|
1295
|
-
confidence: "high"
|
|
1296
|
-
});
|
|
1297
|
-
}
|
|
1298
|
-
if (!/Scope[\s*]*:/.test(entry.block)) {
|
|
1299
|
-
findings.push({
|
|
1300
|
-
id: `${LENS_ID4}/scope-missing:${file}:${entry.id}`,
|
|
1301
|
-
lens: LENS_ID4,
|
|
1302
|
-
tier: "gap",
|
|
1303
|
-
claim: `${file} ${entry.id} has no Scope: field`,
|
|
1304
|
-
evidence: [`${file}: ${entry.id}`],
|
|
1305
|
-
why: "A decision without a scope binds nobody \u2014 a reader cannot tell whether it covers the project they are working in.",
|
|
1306
|
-
action: "Add a Scope: line naming what the decision binds.",
|
|
1307
|
-
effort: "S",
|
|
1308
|
-
confidence: "high"
|
|
1309
|
-
});
|
|
1310
|
-
}
|
|
1311
|
-
const revisit = /Revisit[\s*]*:[\s*]*(\d{4}-\d{2}-\d{2})/.exec(entry.block);
|
|
1312
|
-
if (revisit && revisit[1] < today) {
|
|
1313
|
-
findings.push({
|
|
1314
|
-
id: `${LENS_ID4}/revisit-due:${file}:${entry.id}`,
|
|
1315
|
-
lens: LENS_ID4,
|
|
1316
|
-
tier: "gap",
|
|
1317
|
-
claim: `${file} ${entry.id} was due for revisit on ${revisit[1]}`,
|
|
1318
|
-
evidence: [`${file}: ${entry.id} Revisit: ${revisit[1]}`],
|
|
1319
|
-
why: "Review debt is due \u2014 a Revisit date is a promise to re-evaluate, and a past one silently hardens into policy.",
|
|
1320
|
-
action: "Re-evaluate the decision: supersede it or move the Revisit date.",
|
|
1321
|
-
effort: "S",
|
|
1322
|
-
confidence: "high"
|
|
1323
|
-
});
|
|
1324
|
-
}
|
|
1325
|
-
}
|
|
1326
|
-
return findings;
|
|
1327
|
-
}
|
|
1328
|
-
var stateFreshnessLens = {
|
|
1329
|
-
id: LENS_ID4,
|
|
1330
|
-
version: "1",
|
|
1331
|
-
title: "State freshness",
|
|
1332
|
-
kind: "truth",
|
|
1333
|
-
async run(ctx) {
|
|
1334
|
-
const budgets = ctx.config?.config.state ?? DEFAULT_CONFIG.state;
|
|
1335
|
-
const findings = [];
|
|
1336
|
-
const disclosures = [...ctx.config?.problems ?? []];
|
|
1337
|
-
const outOfScope = [];
|
|
1338
|
-
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1339
|
-
const stateArtifacts = ctx.facts.artifacts.filter((a) => a.kind === "state" && a.exists);
|
|
1340
|
-
const decisionArtifacts = ctx.facts.artifacts.filter((a) => a.kind === "decisions" && a.exists);
|
|
1341
|
-
if (stateArtifacts.length === 0 && decisionArtifacts.length === 0) {
|
|
1342
|
-
disclosures.push("No state or decisions artifacts detected \u2014 nothing to check.");
|
|
1343
|
-
}
|
|
1344
|
-
const freshness = ctx.facts.freshness;
|
|
1345
|
-
if (!freshness) {
|
|
1346
|
-
disclosures.push("Scan carried no freshness facts \u2014 staleness unchecked.");
|
|
1347
|
-
} else {
|
|
1348
|
-
for (const u of freshness.unverifiable) {
|
|
1349
|
-
disclosures.push(`Freshness of ${u.path} is unverifiable (${u.reason}) \u2014 not flagged.`);
|
|
1350
|
-
}
|
|
1351
|
-
for (const a of stateArtifacts) {
|
|
1352
|
-
const fact = freshness.artifacts.find((f) => f.artifactId === a.id);
|
|
1353
|
-
if (!fact || !freshness.repoLastCommit) continue;
|
|
1354
|
-
if (fact.dirty) {
|
|
1355
|
-
disclosures.push(
|
|
1356
|
-
`${a.path} has uncommitted changes \u2014 modified since its last commit; treated fresh-now, not flagged.`
|
|
1357
|
-
);
|
|
1358
|
-
continue;
|
|
1359
|
-
}
|
|
1360
|
-
if (!fact.commitsSince) continue;
|
|
1361
|
-
const gapDays = Math.floor(
|
|
1362
|
-
(Date.parse(freshness.repoLastCommit) - Date.parse(fact.lastCommit)) / MS_PER_DAY
|
|
1363
|
-
);
|
|
1364
|
-
if (gapDays <= budgets.staleAfterDays) continue;
|
|
1365
|
-
const escalated = gapDays > budgets.staleAfterDays * 3;
|
|
1366
|
-
findings.push({
|
|
1367
|
-
id: `${LENS_ID4}/stale-state:${a.path}`,
|
|
1368
|
-
lens: LENS_ID4,
|
|
1369
|
-
tier: escalated ? "risk" : "gap",
|
|
1370
|
-
claim: `${a.path} trails the repo by ${gapDays} days of commit traffic \u2014 it says "now" but the repo moved on`,
|
|
1371
|
-
evidence: [
|
|
1372
|
-
`${a.path} last commit: ${fact.lastCommit}`,
|
|
1373
|
-
`repo last commit: ${freshness.repoLastCommit}`
|
|
1374
|
-
],
|
|
1375
|
-
why: escalated ? `Over three times the ${budgets.staleAfterDays}-day threshold while commits kept landing \u2014 every session starts from a picture of the project that is no longer true.` : `A state doc more than ${budgets.staleAfterDays} days behind continued commit traffic misleads every session that loads it.`,
|
|
1376
|
-
action: "Refresh the state doc (or record why it is still current).",
|
|
1377
|
-
effort: "S",
|
|
1378
|
-
confidence: "high"
|
|
1379
|
-
});
|
|
1380
|
-
}
|
|
1381
|
-
}
|
|
1382
|
-
for (const a of stateArtifacts) {
|
|
1383
|
-
const text = await readText(path.join(ctx.root, a.path));
|
|
1384
|
-
if (text === null) {
|
|
1385
|
-
disclosures.push(`${a.path} could not be read \u2014 unexamined, not clean.`);
|
|
1386
|
-
outOfScope.push(a.path);
|
|
1387
|
-
continue;
|
|
1388
|
-
}
|
|
1389
|
-
if (text.length > budgets.maxChars) {
|
|
1390
|
-
findings.push({
|
|
1391
|
-
id: `${LENS_ID4}/state-over-budget:${a.path}`,
|
|
1392
|
-
lens: LENS_ID4,
|
|
1393
|
-
tier: "gap",
|
|
1394
|
-
claim: `${a.path} is ${text.length} chars \u2014 over the ${budgets.maxChars}-char state budget`,
|
|
1395
|
-
evidence: [`${a.path}: ${text.length} chars`],
|
|
1396
|
-
why: "Session-injection hooks truncate state around 10,000 chars \u2014 an over-budget state doc gets cut mid-sentence, and every session pays its full weight before the task begins.",
|
|
1397
|
-
action: "Trim to budget; move overflow into decisions/docs and keep a pointer.",
|
|
1398
|
-
effort: "M",
|
|
1399
|
-
confidence: "high"
|
|
1400
|
-
});
|
|
1401
|
-
}
|
|
1402
|
-
}
|
|
1403
|
-
for (const a of decisionArtifacts) {
|
|
1404
|
-
const text = await readText(path.join(ctx.root, a.path));
|
|
1405
|
-
if (text === null) {
|
|
1406
|
-
disclosures.push(
|
|
1407
|
-
`${a.path} recognized as a decisions convention (directory) \u2014 age-exempt; per-entry format checks apply only to marker-carrying decisions files.`
|
|
1408
|
-
);
|
|
1409
|
-
continue;
|
|
1410
|
-
}
|
|
1411
|
-
const entries = parseDecisionEntries(text);
|
|
1412
|
-
findings.push(...checkIdSequence(a.path, entries));
|
|
1413
|
-
const format = parseDecisionsFormat(text);
|
|
1414
|
-
if (!format) {
|
|
1415
|
-
disclosures.push(
|
|
1416
|
-
`${a.path} carries no \`${DECISIONS_FORMAT_MARKER}\` marker \u2014 format checks skipped (forward-only, never retroactive); id-sequence checks still ran.`
|
|
1417
|
-
);
|
|
1418
|
-
outOfScope.push(a.path);
|
|
1419
|
-
continue;
|
|
1420
|
-
}
|
|
1421
|
-
for (const problem of format.problems) disclosures.push(`${a.path}: ${problem}`);
|
|
1422
|
-
if (format.fields.length > 0) {
|
|
1423
|
-
disclosures.push(
|
|
1424
|
-
`${a.path} declares required entry fields: ${format.fields.join(", ")} \u2014 checked on every entry (etymd attaches no meaning to the names).`
|
|
1425
|
-
);
|
|
1426
|
-
}
|
|
1427
|
-
findings.push(...checkFormatFields(a.path, entries, today, format.fields));
|
|
1428
|
-
}
|
|
1429
|
-
disclosures.push(
|
|
1430
|
-
`Thresholds: staleAfterDays ${budgets.staleAfterDays} (3x escalates to risk), state budget ${budgets.maxChars} chars (${budgets.staleAfterDays === DEFAULT_CONFIG.state.staleAfterDays && budgets.maxChars === DEFAULT_CONFIG.state.maxChars ? `defaults \u2014 override under \`state\` in ${CONFIG_FILE}` : `set in ${CONFIG_FILE}`}). Decisions artifacts are exempt from age \u2014 old decisions are history, not defects.`
|
|
1431
|
-
);
|
|
1432
|
-
return {
|
|
1433
|
-
lens: LENS_ID4,
|
|
1434
|
-
version: "1",
|
|
1435
|
-
title: "State freshness",
|
|
1436
|
-
kind: "truth",
|
|
1437
|
-
status: "ran",
|
|
1438
|
-
disclosures,
|
|
1439
|
-
findings,
|
|
1440
|
-
...outOfScope.length ? { outOfScope } : {}
|
|
1441
|
-
};
|
|
1442
|
-
}
|
|
1443
|
-
};
|
|
1444
1548
|
|
|
1445
1549
|
// src/engine/run.ts
|
|
1446
1550
|
var LENSES = [
|